- Admin screen: the simple Screen Time setup, the web addresses the app needs, addresses devices were seen reaching, and a Check this device test - Report-only Content-Security-Policy so devices report any other address a page loads (nothing is blocked) - Pictures from other sites pass through the hub (/api/img): podcast search covers, Plex Home pictures, a VBV cover, search answers - docs/v2-roadmap.md: the family-first 2.0 plan, with Family Chat (text only) and no photos or calls Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d5aaad41a
commit
74592c25a5
14 files changed
+224
-7
No files matched your search
@@ -60,6 +60,7 @@
|
||||
<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>
|
||||
|
||||
@@ -82,7 +82,7 @@ async function plexHomeBadge(st) {
|
||||
}
|
||||
function plexSwitchSheet(users) {
|
||||
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
||||
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
||||
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(stOk(u.thumb))}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
||||
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
|
||||
@@ -65,7 +65,7 @@ async function pdHome() {
|
||||
catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; }
|
||||
};
|
||||
const show = list => {
|
||||
found = list; res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||
found = list; res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(stOk(f.image))}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
|
||||
};
|
||||
pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => `<button class="btn ghost" data-t="${esc(t)}" style="min-height:32px;padding:0 12px">${esc(t)}</button>`).join('');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
// ---- iPad and Screen Time (admin): which web addresses the app needs, the ones devices were seen reaching, and a check you run on the device itself.
|
||||
const stOk = u => /^https?:\/\//i.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''); // a picture from another site, passed through the hub so the browser only talks to the hub
|
||||
async function screenTimeSheet() {
|
||||
let d; try { d = await jfetch('/api/screentime'); } catch (e) { return toast(e.message); }
|
||||
const row = (x, extra = '') => `<div class="row"><span class="grow"><b>${esc(x.host)}</b><small style="white-space:normal">${esc(x.why || ((x.what || []).join(', ') + ' · seen ' + x.n + (x.n === 1 ? ' time' : ' times')))}</small></span>${extra}<span class="stres" data-h="${esc(x.host)}"></span><button class="ibtn" data-c="${esc(x.host)}" aria-label="Copy ${esc(x.host)}" title="Copy">${icon('copy')}</button></div>`;
|
||||
const hub = d.required[0].host;
|
||||
const el = openSheet('iPad and Screen Time', 'Let the hub through Screen Time without adding each site one by one', `
|
||||
<p style="margin:0 0 8px"><b>The simple way.</b> On the iPad: <b>Settings → Screen Time → Content & Privacy Restrictions → Content Restrictions → Web Content</b>. Choose <b>Limit Adult Websites</b>, then under <b>Always Allow</b> tap <b>Add Website</b> and enter <b>${esc(hub)}</b>. Nothing else needs adding: the hub carries the music, books, podcasts and pictures itself.</p>
|
||||
<p class="sub" style="white-space:normal">Only if you use <b>Allowed Websites Only</b> do you need to add every address below.</p>
|
||||
<h2>Addresses the app needs</h2><div class="list">${d.required.map(x => row(x)).join('')}</div>
|
||||
${d.seen.length ? `<h2>Others your devices reached</h2><p class="sub" style="white-space:normal">Seen while people used the app (a podcast cover, a weather extension, a site you added). Add only what you want available.</p><div class="list">${d.seen.map(x => row(x)).join('')}</div>` : '<p class="sub" style="white-space:normal">No other addresses have been seen yet. Use the app for a day on the iPad and any others will show up here.</p>'}
|
||||
<h2>Only sometimes</h2><div class="list">${d.optional.map(x => row(x)).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="stcopy" style="flex:1;justify-content:center">Copy the list</button><button class="btn" id="stchk" style="flex:1;justify-content:center">Check this device</button></div>
|
||||
<p class="sub" style="white-space:normal">“Check this device” tries each address from the device you are on, and shows which ones Screen Time (or the network) is blocking. Run it on the iPad.</p>
|
||||
${d.seen.length ? '<div class="bar"><button class="btn ghost" id="stclr">Clear the seen list</button></div>' : ''}`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { navigator.clipboard.writeText(b.dataset.c).then(() => toast('Copied ' + b.dataset.c), () => toast(b.dataset.c)); });
|
||||
const all = [...d.required, ...d.seen, ...d.optional].map(x => x.host);
|
||||
el.querySelector('#stcopy').onclick = () => navigator.clipboard.writeText(all.join('\n')).then(() => toast('Copied ' + all.length + ' addresses'), () => toast('Could not copy'));
|
||||
el.querySelector('#stchk').onclick = async e => {
|
||||
const b = e.currentTarget; b.disabled = true; b.textContent = 'Checking…';
|
||||
await Promise.all(all.map(async h => {
|
||||
const mark = el.querySelector(`.stres[data-h="${CSS.escape(h)}"]`); if (!mark) return;
|
||||
try { await fetch(h === location.hostname ? '/manifest.webmanifest' : 'https://' + h + '/', { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(8000) }); mark.textContent = ' ✓'; mark.style.color = 'var(--accent)'; mark.title = 'Reachable'; }
|
||||
catch { mark.textContent = ' ✗'; mark.style.color = 'var(--danger)'; mark.title = 'Blocked or not reachable from this device'; }
|
||||
}));
|
||||
b.disabled = false; b.textContent = 'Check again';
|
||||
};
|
||||
if (el.querySelector('#stclr')) el.querySelector('#stclr').onclick = async () => { try { await jfetch('/api/screentime', { method: 'DELETE' }); closeSheet(); toast('Cleared'); } catch (x) { toast(x.message); } };
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function viewSearch() {
|
||||
const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `<a class="row" href="#/mail/m/${enc(m.id)}"><span class="ic" style="background:#d4392f">${icon('mail')}</span><span class="grow"><b>${esc(m.subject)}</b><small>${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}</small></span></a>`).join('');
|
||||
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
||||
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
||||
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||
${d.image ? `<img alt="" src="${esc(stOk(d.image))}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
|
||||
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
|
||||
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
|
||||
|
||||
+2
-1
@@ -95,6 +95,7 @@ function viewSettings() {
|
||||
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
|
||||
<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>` : ''}
|
||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||
</div></div>`;
|
||||
@@ -130,7 +131,7 @@ function viewSettings() {
|
||||
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
|
||||
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
|
||||
const lk = $('#lookups'); if (lk) { lk.checked = store.get('lookups', true) !== false; lk.onchange = () => { store.set('lookups', lk.checked); toast(lk.checked ? 'Lookups on' : 'Lookups off'); }; }
|
||||
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
|
||||
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
|
||||
if (isAdmin) jfetch('/api/health').then(h => {
|
||||
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||
const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5);
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v57';
|
||||
const CACHE = 'hub-v58';
|
||||
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'];
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', (
|
||||
const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
|
||||
const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); };
|
||||
const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
|
||||
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(s.cover || '')}" onerror="this.remove()"></span>`;
|
||||
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(stOk(s.cover))}" onerror="this.remove()"></span>`;
|
||||
|
||||
async function viewVbv(args = []) {
|
||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
|
||||
|
||||
Reference in new issue
Block a user