Parental controls: approvals lift bedtime, bedtime follows the child's clock, urgent push + banner + reminders for requests, reward cap raised to 60
build-container / build (push) Successful in 4m3s
build-container / build (push) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0e4e82e0a
commit
0a33119807
11 files changed
+59
-19
No files matched your search
+16
-1
@@ -408,9 +408,24 @@ const kidUi = { st: null, timer: null, told: new Set() };
|
||||
async function kidStart() {
|
||||
if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return;
|
||||
kidAskButton();
|
||||
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea() }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
|
||||
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
|
||||
kidUi.tick = tick; await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
|
||||
}
|
||||
// ---- A parent: a pill at the top of every screen while a child is asking (with a beep and a title count), so a request is never missed. Tap it to answer.
|
||||
const askWatch = { n: 0, timer: null, tick: null };
|
||||
async function askWatchStart() {
|
||||
if (!ME || ME.role !== 'admin' || askWatch.timer) return; const base = document.title.replace(/^\(\d+\) /, '');
|
||||
const tick = async () => {
|
||||
let r; try { r = await jfetch('/api/kid/requests'); } catch { return; }
|
||||
const open = (r.requests || []).filter(x => x.status === 'open'), who = [...new Set(open.map(x => x.userName))]; let p = document.getElementById('askpill');
|
||||
if (open.length > askWatch.n) { try { toast('🙋 ' + who.join(', ') + (open.some(x => x.time) ? ' is asking for time' : ' is asking')); const a = new (window.AudioContext || window.webkitAudioContext)(), o = a.createOscillator(), g = a.createGain(); o.connect(g); g.connect(a.destination); o.frequency.value = 880; g.gain.setValueAtTime(.15, a.currentTime); g.gain.exponentialRampToValueAtTime(.001, a.currentTime + .5); o.start(); o.stop(a.currentTime + .5); } catch {} }
|
||||
askWatch.n = open.length; document.title = (open.length ? '(' + open.length + ') ' : '') + base;
|
||||
if (!open.length) { if (p) p.remove(); return; }
|
||||
if (!p) { p = document.createElement('button'); p.id = 'askpill'; p.type = 'button'; p.onclick = () => { location.hash = '#/settings/family'; if (askWatch.tick) askWatch.tick(); }; document.body.appendChild(p); }
|
||||
p.textContent = '🙋 ' + (who.length > 2 ? who.length + ' children are' : who.join(' and ') + (who.length > 1 ? ' are' : ' is')) + ' asking' + (open.some(x => x.time) ? ' for time' : '') + (open.length > 1 ? ' (' + open.length + ')' : '');
|
||||
};
|
||||
askWatch.tick = tick; await tick(); askWatch.timer = setInterval(tick, 20e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
|
||||
}
|
||||
/** Which part of the app the child is in, so the parents can see where the time goes (and limit one part). */
|
||||
function kidArea() { const r = (location.hash.slice(2).split('/')[0] || 'home'), h = location.hash; return r === 'video' ? 'video' : r === 'music' ? 'music' : /^(audio|podcast|radio|listen|vbv)$/.test(r) || /^#\/store\/(audiobooks|radio)/.test(h) ? 'listen' : /^(read|reading|bible)$/.test(r) || /^#\/store\/books/.test(h) ? 'read' : /^(chat|signal)$/.test(r) ? 'chat' : 'other'; }
|
||||
const actSeen = new Map();
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ for (const ev of ['gesturestart', 'gesturechange', 'gestureend']) document.addEv
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
if (typeof themeStart === 'function') themeStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof askWatchStart === 'function') askWatchStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
|
||||
@@ -29,7 +29,7 @@ async function famRequests(box) {
|
||||
const open = r.requests.filter(x => x.status === 'open'); if (!open.length) { box.innerHTML = ''; return; }
|
||||
const when = t => new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' });
|
||||
box.innerHTML = `<h2 style="margin-top:0">Asking you (${open.length})</h2><div class="list">${open.map(q => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:200px"><b>${esc(q.userName)}: ${esc(q.text)}</b><small style="white-space:normal">${when(q.at)}${q.tab ? ' · wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' · +' + q.minutes + ' min' : ''}</small></span>${q.time ? [15, 30, 60].map(m => `<button class="btn" data-q="${esc(q.id)}" data-y="1" data-m="${m}">+${m === 60 ? '1 h' : m + ' min'}</button>`).join('') : `<button class="btn" data-q="${esc(q.id)}" data-y="1">Yes</button>`}<button class="btn ghost" data-q="${esc(q.id)}">No</button></div>`).join('')}</div>`;
|
||||
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } });
|
||||
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); if (typeof askWatch === 'object' && askWatch.tick) askWatch.tick(); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking.
|
||||
async function famKids(box) {
|
||||
|
||||
@@ -1211,3 +1211,7 @@ body.qdock #view { padding-right: 340px; }
|
||||
#imgview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; touch-action: pinch-zoom; -webkit-touch-callout: default; user-select: auto; }
|
||||
#imgview .ivx { position: absolute; top: calc(var(--sat, 0px) + 8px); right: 12px; background: rgba(255, 255, 255, .16); color: #fff; }
|
||||
#imgview .ivsave { position: absolute; top: calc(var(--sat, 0px) + 16px); left: 16px; color: #fff; font-size: 16px; padding: 6px 12px; border-radius: 16px; background: rgba(255, 255, 255, .16); text-decoration: none; cursor: pointer; }
|
||||
#askpill { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 10px); left: 50%; transform: translateX(-50%); z-index: 70; max-width: calc(100vw - 24px); padding: 9px 18px; border-radius: 99px; border: 0; background: var(--danger); color: #fff; font: inherit; font-weight: 700; font-size: 14px; box-shadow: 0 4px 16px rgba(0,0,0,.35); cursor: pointer; animation: askpulse 1.8s ease-in-out infinite; }
|
||||
@keyframes askpulse { 50% { box-shadow: 0 4px 22px rgba(217,69,58,.85); } }
|
||||
@media (prefers-reduced-motion: reduce) { #askpill { animation: none; } }
|
||||
body:has(#sheet) #askpill { display: none; }
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v141';
|
||||
const CACHE = 'hub-v143';
|
||||
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'];
|
||||
@@ -86,7 +86,7 @@ self.addEventListener('fetch', e => {
|
||||
// Push reminders (see push.js on the server)
|
||||
self.addEventListener('push', e => {
|
||||
let d = {}; try { d = e.data.json(); } catch {}
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, requireInteraction: !!d.urgent, renotify: !!d.urgent, vibrate: d.urgent ? [200, 100, 200, 100, 200] : undefined, actions: (d.actions || []).slice(0, 2), data: { url: d.url || '#/home', req: d.req } }));
|
||||
});
|
||||
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
|
||||
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
@@ -98,6 +98,8 @@ self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||
})()));
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const rid = e.notification.data && e.notification.data.req; // "Yes" / "Not now" on a child's request answers it without opening the app
|
||||
if (rid && (e.action === 'yes' || e.action === 'no')) return e.waitUntil(fetch('/api/kid/requests/' + rid, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ approve: e.action === 'yes' }) }).then(r => r.ok ? self.registration.showNotification(e.action === 'yes' ? 'Approved' : 'Declined', { body: 'The child has been told.', icon: '/icons/icon-192.png', tag: 'ans-done', silent: true }) : self.clients.openWindow('/#/settings/family')).catch(() => self.clients.openWindow('/#/settings/family')));
|
||||
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
||||
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => {
|
||||
const c = cs.find(x => new URL(x.url).origin === self.location.origin);
|
||||
|
||||
Reference in new issue
Block a user