'use strict'; // If the server says the passkey session is gone, go back to the sign-in page. 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) return r; }; const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different // device, or the separate storage of the iOS home-screen app. Only the music queue stays local. const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; for (const [key, value] of [...outbox]) { outbox.delete(key); fetch('/api/settings', { method: 'PUT', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) }) .then(r => { if (!r.ok) throw new Error('save failed'); }) .catch(() => { if (!warned) { warned = true; toast('Could not save your settings to the server. They are kept on this device for now.'); } }); } } const queueSetting = (k, v) => { if (!SYNCED.has(k)) return; outbox.set(k, v); clearTimeout(outTimer); outTimer = setTimeout(flushSettings, 400); }; addEventListener('pagehide', () => { if (outbox.size) flushSettings(); }); // Several people can use the same browser, so everything kept in the browser is filed under the signed-in person. let ME = null; const nk = k => (ME ? ME.id + ':' + k : k); const store = { get(k, d) { try { let v = localStorage.getItem(nk(k)); if (v === null && ME && ME.role === 'admin') v = localStorage.getItem(k); /* kept from before accounts existed */ return JSON.parse(v) ?? d; } catch { return d; } }, set(k, v) { try { localStorage.setItem(nk(k), JSON.stringify(v)); } catch {} queueSetting(k, v); }, del(k) { try { localStorage.removeItem(nk(k)); if (ME && ME.role === 'admin') localStorage.removeItem(k); } catch {} queueSetting(k, null); }, }; async function loadMe() { try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } } catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides) } // Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up. const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)'); // Related tabs share one menu entry (a row of buttons at the top of the page switches between them); each still needs its own permission. const MERGED = { listen: ['Listen', ['music', 'radio', 'podcast', 'audio']], reading: ['Read', ['bible', 'read']] }; const allowed = id => MERGED[id] ? MERGED[id][1].some(allowed) : id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up) /** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */ async function loadSettings() { try { const r = await fetch('/api/settings'); if (!r.ok) return; const { values } = await r.json(); for (const k of SYNCED) { if (k in values) { try { localStorage.setItem(nk(k), JSON.stringify(values[k])); } catch {} } else { const local = store.get(k); if (local !== undefined && local !== null) queueSetting(k, local); } } } catch {} // offline: keep using what this browser has } const ICONS = { film: '', lyrics: '', chat: '', clip: '', send: '', menu: '', home: '', mail: '', files: '', doc: '', cog: '', chev: '', back: '', edit: '', up: '', newdir: '', file: '', dl: '', audio: '', sort: '', refresh: '', full: '', ext: '', copy: '', music: '', rew: '', fwd: '', vol: '', mute: '', pip: '', play: '', pause: '', next: '', spin: '', x: '', prev: '', repeat: '', shuffle: '', more: '', cal: '', store: '', download: '', arrup: '', arrdn: '', bell: '', clock: '', chef: '', people: '', search: '', book: '', mic: '', plus: '', link: '', check: '', trash: '', bookmark: '', radio: '', heart: '', bible: '', list: '', font: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; // ---- Seafile client (via same-origin proxy) const sf = { token: () => store.get('sf_token'), /** A call to Seafile through the hub. Reads are retried; a slow or unreachable Seafile gives a clear message; an expired sign-in asks for the * password again in place (sfReauth) and then carries on, so you do not lose where you were. */ async req(path, opts = {}, tries = 0) { const { _reauthed, timeout, ...rest } = opts, method = (rest.method || 'GET').toUpperCase(), idem = method === 'GET' || method === 'HEAD'; const headers = { Authorization: 'Token ' + sf.token(), ...(rest.headers || {}) }; let r; try { r = await fetch('/sf' + path, { ...rest, headers, signal: rest.signal || AbortSignal.timeout(timeout || 45000) }); } catch (e) { if (idem && tries < 2 && e.name !== 'AbortError' && navigator.onLine) { await sleep(600 * (tries + 1)); return sf.req(path, opts, tries + 1); } throw new Error(!navigator.onLine ? 'You are offline' : e.name === 'TimeoutError' ? 'Seafile took too long to answer. Try again in a moment.' : 'Could not reach the server'); } if (r.status === 401 && !_reauthed) { await sfReauth(); return sf.req(path, { ...opts, _reauthed: true }); } if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); } if (!r.ok) { let m = (await r.text().catch(() => '')).slice(0, 200); try { const j = JSON.parse(m); m = j.error || j.error_msg || j.detail || m; } catch {} throw new Error(m || (r.status === 504 ? 'Seafile took too long to answer' : r.status >= 500 ? 'Seafile had a problem (' + r.status + ')' : 'Request failed (' + r.status + ')')); } const t = await r.text(); try { return JSON.parse(t); } catch { return t; } }, async login(username, password, otp) { const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (otp) headers['X-SEAFILE-OTP'] = otp; const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) }); const j = await r.json().catch(() => ({})); if (!r.ok) { if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true }); throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' ')); } store.set('sf_token', j.token); }, }; /** A short message at the bottom; with `action` ({ label, run }) it has a button (Undo, say) and stays a little longer. */ const toast = (m, action) => { const t = $('#toast'); t.textContent = m; t.classList.toggle('act', !!action); if (action) { const b = document.createElement('button'); b.textContent = action.label; b.onclick = () => { t.classList.remove('on'); action.run(); }; t.appendChild(b); } t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), action ? 9000 : 2600); }; const ext = n => (n.split('.').pop() || '').toLowerCase(); const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']); const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']); const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB'; const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1]; if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; } if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; }; /** Every page one by one (what search and the command palette list), with the merged entries split back out. */ const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0])); const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1]; /** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */ const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; const navGroups = (tabs = navTabs()) => { const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) })); const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0]))); return out.filter(g => g.tabs.length); }; const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; // The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More". const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail']; const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); }; const navRecent = () => { try { const l = JSON.parse(localStorage.getItem(nk('nav_recent'))); return Array.isArray(l) ? l : []; } catch { return []; } }; const navTouch = id => { if (!id || id === 'home' || id === 'settings') return; try { localStorage.setItem(nk('nav_recent'), JSON.stringify([id, ...navRecent().filter(x => x !== id)].slice(0, 8))); } catch {} }; const phone = matchMedia('(max-width: 767px)'); let activeTab = 'home'; function renderNav(active = activeTab) { activeTab = active; if (typeof sgDockBadge === 'function') sgDockBadge(); if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere(); const badge = n => n ? `${n > 99 ? '99+' : n}` : ''; const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}`; const brand = `
NE Hub
`; const mine = navTabs(); if (phone.matches) { const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active); $('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') + (rest.length ? `${icon('more')}More${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}` : ''); const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); }; } else { $('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `` : '') + g.tabs.map(tab).join('')).join('') + `
`; $('#navcol').onclick = () => setNav({ mini: !navPref().mini }); } } phone.addEventListener('change', () => renderNav()); /** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */ function openLauncher() { const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `${n > 99 ? '99+' : n}` : ''; }; const tile = ([id, label]) => `${icon(TAB_ICON[id] || 'menu')}${badge(id)}${esc(label)}`; const el = openSheet('Apps', '', `
`); el.querySelector('.sheet').classList.add('launcher'); const box = el.querySelector('#lnb'), draw = q => { q = q.trim().toLowerCase(); if (q) { const hits = leafTabs().filter(t => t[1].toLowerCase().includes(q)).map(([id, l]) => [id, l]); box.innerHTML = hits.length ? `
${hits.map(tile).join('')}
` : '

No app matches.

'; } else { const rec = navRecent().map(id => tabs.find(t => t[0] === id)).filter(Boolean).slice(0, 4); box.innerHTML = (rec.length ? `

Recent

${rec.map(tile).join('')}
` : '') + navGroups(tabs).map(g => `${g.label ? `

${esc(g.label)}

` : ''}
${g.tabs.map(tile).join('')}
`).join(''); } box.querySelectorAll('a.lt').forEach(a => a.addEventListener('click', closeSheet)); }; draw(''); el.querySelector('#lnq').oninput = e => draw(e.target.value); setTimeout(() => { if (document.activeElement && document.activeElement.id === 'lnq') document.activeElement.blur(); }, 0); // (no keyboard popping up until you tap the box) el.querySelector('#lnpin').onclick = () => { closeSheet(); openPinsSheet(); }; } /** Choose which entries sit on the phone's bottom bar (up to five, in the order you tap them). */ function openPinsSheet() { const tabs = navTabs(); let pins = navPins(); const el = openSheet('Bottom bar', 'Tap up to five apps for the bar, in the order you want them. Everything else stays under More.', '
'); const draw = () => { el.querySelector('#pnl').innerHTML = tabs.map(([id, label]) => { const at = pins.indexOf(id); return ``; }).join(''); el.querySelectorAll('#pnl .row').forEach(b => b.onclick = () => { const id = b.dataset.id; if (pins.includes(id)) pins = pins.filter(x => x !== id); else if (pins.length >= 5) return toast('The bar has room for five'); else pins.push(id); draw(); }); }; draw(); el.querySelector('#pnr').onclick = () => { pins = DEFAULT_PINS.filter(id => tabs.some(t => t[0] === id)); draw(); }; el.querySelector('#pns').onclick = () => { store.set('nav_pins', pins.length ? pins : DEFAULT_PINS); closeSheet(); renderNav(); toast('Bottom bar saved'); }; } /** Where a "Listen" or "Read" tap goes: the part you used last. */ const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(nk('nav_last_' + g))); } catch {} return MERGED[g][1].includes(l) && allowed(l) ? l : MERGED[g][1].find(allowed); }; /** The row of buttons under the top bar that switches between the parts of Listen and Read. */ function drawSubnav(name) { const el = $('#subnav'); if (!el) return; const g = leafOf(name), items = g ? MERGED[g][1].filter(allowed) : []; if (!g || items.length < 2) { el.hidden = true; el.innerHTML = ''; return; } try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {} el.innerHTML = items.map(id => `${esc(tabLabel(id))}`).join(''); el.hidden = false; } 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 }); 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 {}; } }; function applyNav() { const p = navPref(); document.body.classList.toggle('nav-mini', !!p.mini); document.body.classList.toggle('nav-hidden', !!p.hidden); } function setNav(patch) { try { localStorage.setItem('nav_pref', JSON.stringify({ ...navPref(), ...patch })); } catch {} applyNav(); } applyNav(); addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); setNav({ hidden: !navPref().hidden }); } }); // In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible. let navIdx = 0, navMax = 0, navStarted = false; const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a function trackNav() { const s = history.state; if (s && s.i != null) navIdx = s.i; else { navIdx = navStarted ? navIdx + 1 : 0; navMax = Math.max(navMax, navIdx); if (navStarted) navMax = navIdx; try { history.replaceState({ ...(s || {}), i: navIdx }, ''); } catch {} } navStarted = true; navMax = Math.max(navMax, navIdx); } function drawNav() { const el = $('#navbar'); if (!el) return; const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax; if (!el.dataset.on) { el.dataset.on = 1; el.innerHTML = ``; $('#nvm').onclick = () => setNav({ hidden: !navPref().hidden }); $('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); }; $('#nvf').onclick = () => history.forward(); } $('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd; const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : ''; el.hidden = false; } addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } }); function route() { trackNav(); const [, id = 'home', ...rest] = location.hash.split('/'); const routes = routesOf(); let name = routes[id] ? id : 'home'; if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs try { editor?.destroyEditor(); } catch {} editor = null; document.body.classList.toggle('editing', name === 'edit'); document.body.classList.remove('inedit', 'onaudio'); sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own) const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame curRoute = name; navTouch(navIdOf(name)); drawSubnav(name); renderNav(navIdOf(name)); drawNav(); routes[name](rest.map(decodeURIComponent)); sgDockSync(name); $('#view').scrollTop = 0; } function tile(href, color, ic, title, sub, external) { return ` ${icon(ic)}${esc(title)}${esc(sub)}`; } // ---- Views /** The Home app tiles, grouped by kind like the menu. */ function homeApps() { const T = { files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'), music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'), video: () => tile('#/video', '#c0392b', 'film', 'Movies & TV', 'Plex'), planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'), office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'), audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'), podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'), chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'), notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'), chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'), store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'), recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'), contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'), radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'), read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'), signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'), links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'), mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), }; T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `

${esc(label)}

${ids.map(id => T[id]()).join('')}
`).join(''); } function viewHome() { const h = new Date().getHours(); const evening = h >= 17 || h < 5; // after work the media comes first; during the day the agenda and tasks do const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; const recents = store.get('recents', []).slice(0, 5); const t = typeof cur === 'function' ? cur() : null; const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); $('#view').innerHTML = `

${greet}

${today}

${inboxBell()}
${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} ${allowed('music') ? '
' : ''} ${evening ? '
' : ''}
${allowed('mail') && cfg.mail ? '
' : ''} ${recents.length && allowed('files') ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''}
${evening ? '' : '
'}
${homeApps()}
`; if (typeof stHomeExt === 'function' && allowed('store')) stHomeExt($('#homeext')); inboxWire(); if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc')); if (typeof homeResume === 'function') homeResume($('#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('/')) || '/'); }); if ($('#qadd')) { $('#qadd').onclick = () => quickAdd(); $('#qpal').onclick = () => openPalette(); } if (allowed('tasks')) renderTasksCard($('#tcard')); if ($('#mcard')) renderMailCard($('#mcard')); if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today'); } // Name search by listing each library recursively (cached for the session). const treeCache = new Map(); async function walkSearch(repos, term) { const out = []; for (const r of repos.filter(x => !x.encrypted).slice(0, 12)) { try { let t = treeCache.get(r.id); if (!t || Date.now() - t.at > 120e3) { t = { at: Date.now(), items: await sf.req(`/api2/repos/${encodeURIComponent(r.id)}/dir/?p=/&recursive=1`) }; treeCache.set(r.id, t); } for (const it of t.items) if (it.name.toLowerCase().includes(term)) { const parent = (it.parent_dir || '/').replace(/\/?$/, '/'); out.push({ repo_id: r.id, repo_name: r.name, name: it.name, fullpath: parent + it.name, is_dir: it.type === 'dir', size: it.size }); if (out.length >= 40) return out; } } catch {} } return out; } // ---- A child account: the app counts the time it is open (a ping each minute), shows how much is left, and locks when the time is up or it is bedtime (the server refuses // requests too, so this screen is only the explanation). "Ask a parent" sends a message to the parents. 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(); }; await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); }); } function kidPaint() { const st = kidUi.st; if (!st || !st.on) return; let chip = document.getElementById('kidchip'); if (st.left !== null && !st.locked) { if (!chip) { chip = document.createElement('div'); chip.id = 'kidchip'; document.body.appendChild(chip); } chip.textContent = '⏱ ' + (st.left >= 60 ? Math.floor(st.left / 60) + ' h ' + (st.left % 60) + ' min' : st.left + ' min') + ' left today'; chip.classList.toggle('low', st.left <= 10); for (const m of [10, 5, 1]) if (st.left <= m && !kidUi.told.has(m) && st.left > 0) { kidUi.told.add(m); toast(st.left + (st.left === 1 ? ' minute' : ' minutes') + ' left today'); break; } } else if (chip) chip.remove(); if (st.locked) kidLock(st); else { const l = document.getElementById('kidlock'); if (l) l.remove(); } } function kidLock(st) { if (document.getElementById('kidlock')) return; try { closeSheet(); } catch {} try { pl.a.pause(); } catch {} const el = document.createElement('div'); el.id = 'kidlock'; el.setAttribute('role', 'alertdialog'); el.innerHTML = `
${st.locked === 'bedtime' ? '🌙' : '⏰'}

${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}

${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}

`; document.body.appendChild(el); el.querySelector('#kidask').onclick = async e => { e.currentTarget.disabled = true; try { const r = await jfetch('/api/kid/ask', { method: 'POST', body: JSON.stringify({ text: st.locked === 'bedtime' ? 'Can I stay up a little longer, please?' : 'Can I have a little more time, please?' }) }); el.querySelector('#kidmsg').textContent = r.told ? 'I asked. Wait for a yes.' : 'Nobody could be asked right now.'; } catch (err) { el.querySelector('#kidmsg').textContent = err.message; e.currentTarget.disabled = false; } }; }