Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
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:
1 parent
aac6f6efc2
commit
301e8017d9
34 files changed
+466
-51
No files matched your search
+22
-3
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user