Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s

- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey
- Music and Movies are separate on the server; Plex can be locked to a child's managed profile
- Activity log, weekly summary and alerts to parents; per-area and weekend limits
- Requests from children (Ask button) answered in Settings; bonus screen time from rewards
- Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children
- Simple mode (big-button home) for young children or grandparents
- Child usage kept in memory instead of read from disk on every request
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 16:49:48 -04:00
1 parent aac6f6efc2
commit 301e8017d9
34 files changed
+466 -51

No files matched your search

+22 -3
View File
@@ -4,7 +4,7 @@ const _fetch = window.fetch.bind(window);
window.fetch = async (...a) => {
const r = await _fetch(...a);
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); // (a child account whose time is up: show the lock screen)
if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { if (String(r.headers.get('x-hub-limit')).startsWith('area:')) { toast(j.error || 'That time is up for today'); return; } kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); /* (an area's time is up: say so; the whole app locks only when the day's time or bedtime is up) */ // (a child account whose time is up: show the lock screen)
return r;
};
const $ = s => document.querySelector(s);
@@ -257,7 +257,7 @@ function drawSubnav(name) {
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -400,9 +400,28 @@ async function walkSearch(repos, term) {
const kidUi = { st: null, timer: null, told: new Set() };
async function kidStart() {
if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return;
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST' }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
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(); };
await tick(); kidUi.timer = setInterval(tick, 60e3); 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();
/** Tell the server what a child just played, read or watched (for the parents' view). Only child accounts report. */
function actLog(kind, title) { if (!ME || !ME.kid || !ME.kid.on || !title) return; const k = kind + '|' + title, now = Date.now(); if (now - (actSeen.get(k) || 0) < 10 * 60e3) return; actSeen.set(k, now); jfetch('/api/kid/activity', { method: 'POST', body: JSON.stringify({ kind, title }) }).catch(() => {}); }
/** A small "Ask" button for a child: ask a parent for something (a movie, a book, a part of the hub, more time). */
function kidAskButton() {
if (document.getElementById('kidreq')) return; const b = document.createElement('button'); b.id = 'kidreq'; b.type = 'button'; b.textContent = '🙋 Ask'; b.setAttribute('aria-label', 'Ask a parent for something'); document.body.appendChild(b);
b.onclick = () => {
const off = (typeof TABS !== 'undefined' ? TABS : []).filter(t => !allowed(t[0]) && !['settings', 'home', 'family'].includes(t[0]));
const el = openSheet('Ask a parent', 'Tell them what you would like. They will see it and answer.', `<form id="krf"><label for="krt">What would you like?</label><input type="text" id="krt" maxlength="200" placeholder="Can I watch a movie? Can I have this book?" autocomplete="off" required>
${off.length ? `<label for="krb">Open a part of the app (optional)</label><select id="krb"><option value="">Nothing</option>${off.map(t => `<option value="${esc(t[0])}">${esc(t[1])}</option>`).join('')}</select>` : ''}
<label for="krm">More time (optional)</label><select id="krm"><option value="0">No</option><option value="15">15 minutes</option><option value="30">30 minutes</option><option value="60">An hour</option></select>
<div class="bar"><button type="button" class="btn ghost" id="krx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Send</button></div></form>`);
el.querySelector('#krx').onclick = closeSheet;
el.querySelector('#krf').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/kid/requests', { method: 'POST', body: JSON.stringify({ text: el.querySelector('#krt').value, tab: el.querySelector('#krb') ? el.querySelector('#krb').value : '', minutes: +el.querySelector('#krm').value }) }); closeSheet(); toast('Sent. A parent will answer.'); } catch (x) { toast(x.message); } };
};
}
function kidPaint() {
const st = kidUi.st; if (!st || !st.on) return; let chip = document.getElementById('kidchip');
if (st.left !== null && !st.locked) {