Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
build-container / build (push) Successful in 4m6s
- Chores tab with repeats, points and a week/month board - Family page: calendar, chores, shopping, notes, chat and tasks on one start page - Genre search in the Store, Free Books and LibriVox - Child accounts: safe content, daily limit and bedtime, ask a parent - Search now covers notes, chat, contacts, chores, recipes and your books - Notifications center: bell with a per-person list of chat, reminders and announcements - Roadmap, README and tests updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf292e3bc7
commit
0383da87f5
38 files changed
+720
-79
No files matched your search
+35
-7
@@ -4,6 +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)
|
||||
return r;
|
||||
};
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -168,8 +169,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['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', notes: 'doc', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
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];
|
||||
@@ -179,14 +180,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
|
||||
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', 'search']], ['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
|
||||
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', notes: '#e5b800', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
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); };
|
||||
@@ -256,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), 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: 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 {}; } };
|
||||
@@ -324,6 +325,7 @@ function homeApps() {
|
||||
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'),
|
||||
@@ -336,7 +338,7 @@ function homeApps() {
|
||||
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', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||
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]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
@@ -349,7 +351,7 @@ function viewHome() {
|
||||
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
$('#view').innerHTML = `<div class="wrap home">
|
||||
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
|
||||
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
||||
<div class="homeact">${inboxBell()}<button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
||||
<div id="annc"></div>
|
||||
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
||||
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
||||
@@ -362,6 +364,7 @@ function viewHome() {
|
||||
${evening ? '' : '<div id="resume"></div>'}
|
||||
<div class="homeapps">${homeApps()}</div><div id="homeext"></div></div>`;
|
||||
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)
|
||||
@@ -391,3 +394,28 @@ async function walkSearch(repos, term) {
|
||||
}
|
||||
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 = `<div class="kidbox"><div style="font-size:56px">${st.locked === 'bedtime' ? '🌙' : '⏰'}</div><h1>${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}</h1><p>${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}</p><button class="btn" id="kidask">Ask a parent for more time</button><p class="sub" id="kidmsg"></p></div>`;
|
||||
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; } };
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
setInterval(checkForUpdate, 10 * 60e3);
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
sgStart(); if (typeof chStart === 'function') chStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
// ---- Chores: today's chores for you and the family, ticking them off, points for the week, and (admin) making and changing chores. See chores.js.
|
||||
const cr = { day: '', data: null, range: 'week', sum: null };
|
||||
const crApi = (p, o) => jfetch('/api/chores' + p, o);
|
||||
const crPost = (p, b) => crApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
|
||||
const crYmd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
const crShift = (s, n) => { const d = new Date(s + 'T12:00:00'); d.setDate(d.getDate() + n); return crYmd(d); };
|
||||
const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd(new Date()), -1) ? 'Yesterday' : s === crShift(crYmd(new Date()), 1) ? 'Tomorrow' : new Date(s + 'T12:00:00').toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
|
||||
const CR_REPEAT = { daily: 'Every day', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', once: 'One time' };
|
||||
const CR_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
async function viewChores() {
|
||||
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
|
||||
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
|
||||
try { [cr.data, cr.sum] = await Promise.all([crApi('/?date=' + cr.day), crApi('/summary?range=' + cr.range + '&date=' + cr.day)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/chores')) return;
|
||||
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
|
||||
const item = i => `<label class="critem ${i.done ? 'done' : ''}"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}" ${i.done ? 'checked' : ''} ${i.canDo ? '' : 'disabled'}><span class="grow"><b>${esc(i.title)}</b>${i.notes ? `<small>${esc(i.notes)}</small>` : ''}${i.done ? `<small>Done by ${esc(i.done.byName)}</small>` : i.overdue ? '<small style="color:var(--danger)">Overdue</small>' : ''}</span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`;
|
||||
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
|
||||
const max = Math.max(1, ...cr.sum.people.map(p => p.points));
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
|
||||
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
|
||||
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
|
||||
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
|
||||
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
|
||||
<div class="ptop"><h2 style="margin:18px 0 6px">Points</h2><span class="pspace grow"></span><div class="seg" style="margin:0"><a href="#" data-r="week" class="${cr.range === 'week' ? 'on' : ''}">Week</a><a href="#" data-r="month" class="${cr.range === 'month' ? 'on' : ''}">Month</a></div></div>
|
||||
<div class="crcard">${cr.sum.people.map((p, i) => `<div class="crrank"><span class="num">${i + 1}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar"><i style="width:${Math.round(p.points / max * 100)}%"></i></div></span><small>${p.points} pts · ${p.count} done</small></div>`).join('') || '<p class="empty">No chores yet.</p>'}</div></div>`;
|
||||
v.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: cr.day, user: c.dataset.u || undefined, undo: !c.checked }); if (c.checked) toast('Nice work!'); viewChores(); } catch (e) { toast(e.message); c.checked = !c.checked; } });
|
||||
$('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); };
|
||||
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); cr.range = a.dataset.r; viewChores(); });
|
||||
if ($('#crman')) $('#crman').onclick = crManage;
|
||||
}
|
||||
|
||||
// ---- making and changing chores (admin)
|
||||
function crManage() {
|
||||
const d = cr.data, who = c => c.assign === 'anyone' ? 'Anyone' : d.people.filter(p => c.assign.includes(p.id)).map(p => p.name).join(', ');
|
||||
const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : '');
|
||||
const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `<div class="list" style="max-height:52dvh;overflow:auto">${d.chores.map(c => `<button class="row" data-e="${esc(c.id)}"><span class="grow"><b>${esc(c.title)}${c.active ? '' : ' (paused)'}</b><small style="white-space:normal">${esc(when(c))} · ${esc(who(c))}${c.points ? ' · ' + c.points + ' pts' : ''}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No chores yet.</p>'}</div>
|
||||
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>`);
|
||||
el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); };
|
||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { closeSheet(); crEdit(d.chores.find(c => c.id === b.dataset.e)); });
|
||||
}
|
||||
function crEdit(c) {
|
||||
const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign);
|
||||
const el = openSheet(c ? 'Edit chore' : 'New chore', '', `<form id="cref"><label for="crt">What needs doing</label><input type="text" id="crt" value="${esc(x.title)}" maxlength="120" required autocomplete="off" placeholder="Empty the dishwasher">
|
||||
<label for="crn">Notes (optional)</label><input type="text" id="crn" value="${esc(x.notes)}" maxlength="400" autocomplete="off">
|
||||
<label for="crr">How often</label><select id="crr">${Object.entries(CR_REPEAT).map(([k, l]) => `<option value="${k}"${x.repeat === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<div id="crd" hidden><label>On which days (none: any day that week)</label><div class="seg">${CR_DAYS.map((n, i) => `<a href="#" data-d="${i}" class="${(x.days || []).includes(i) ? 'on' : ''}">${n}</a>`).join('')}</div></div>
|
||||
<div id="crm" hidden><label for="crdom">Day of the month</label><input type="number" id="crdom" min="1" max="31" value="${x.dom || 1}"></div><div id="cro" hidden><label for="crdt">On</label><input type="date" id="crdt" value="${esc(x.date || '')}"></div>
|
||||
<label>Who does it</label><div class="list"><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="anyone"${x.assign === 'anyone' ? ' checked' : ''}><span class="grow"><b>Anyone</b><small>Whoever gets to it first</small></span></label><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="people"${x.assign !== 'anyone' ? ' checked' : ''}><span class="grow"><b>These people</b><small>Each one has their own to do</small></span></label></div>
|
||||
<div class="list" id="crp">${d.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div>
|
||||
<label for="crpt">Points for doing it</label><input type="number" id="crpt" min="0" max="50" value="${x.points || 0}">
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Active</b><small>Turn off to pause it</small></span><input type="checkbox" id="cra"${x.active ? ' checked' : ''}></label><div class="err" id="cre"></div>
|
||||
<div class="bar">${c ? '<button type="button" class="btn danger" id="crdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="crx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
const sync = () => { const r = el.querySelector('#crr').value; el.querySelector('#crd').hidden = r !== 'weekly'; el.querySelector('#crm').hidden = r !== 'monthly'; el.querySelector('#cro').hidden = r !== 'once'; el.querySelector('#crp').hidden = el.querySelector('[name=crw]:checked').value !== 'people'; };
|
||||
el.querySelector('#crr').onchange = sync; el.querySelectorAll('[name=crw]').forEach(r => r.onchange = sync); sync(); el.querySelectorAll('#crd [data-d]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
|
||||
el.querySelector('#crx').onclick = closeSheet;
|
||||
if (c) el.querySelector('#crdel').onclick = () => { closeSheet(); confirmSheet('Delete this chore?', c.title + ' and its history.', 'Delete', async () => { try { await crApi('/' + c.id, { method: 'DELETE' }); toast('Deleted'); viewChores(); } catch (e) { toast(e.message); } }); };
|
||||
el.querySelector('#cref').onsubmit = async e => { e.preventDefault(); const people = [...el.querySelectorAll('[data-p]:checked')].map(i => i.dataset.p);
|
||||
try { await crPost('/', { id: c && c.id, title: el.querySelector('#crt').value, notes: el.querySelector('#crn').value, repeat: el.querySelector('#crr').value, days: [...el.querySelectorAll('#crd .on')].map(a => +a.dataset.d), dom: +el.querySelector('#crdom').value, date: el.querySelector('#crdt').value, assign: el.querySelector('[name=crw]:checked').value === 'anyone' ? 'anyone' : people, points: +el.querySelector('#crpt').value, active: el.querySelector('#cra').checked }); closeSheet(); toast('Saved'); viewChores(); }
|
||||
catch (x) { el.querySelector('#cre').textContent = x.message; } };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
|
||||
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
|
||||
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
|
||||
const fmBody = id => document.querySelector('#' + id + ' .fmb');
|
||||
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
|
||||
|
||||
function viewFamily() {
|
||||
const h = new Date().getHours(), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening', today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
const cards = [['cal', allowed('calendar'), fmCard('fmcal', 'Today and tomorrow', '#/calendar', 'Calendar')], ['chores', allowed('chores'), fmCard('fmch', 'Chores today', '#/chores')], ['shop', allowed('recipes'), fmCard('fmsh', 'Shopping list', '#/recipes/shop')],
|
||||
['notes', allowed('notes'), fmCard('fmnt', 'Family notes and lists', '#/notes')], ['chat', allowed('chat'), fmCard('fmct', 'Chat', '#/chat')], ['tasks', allowed('tasks'), fmCard('fmtk', 'Your tasks', '#/tasks')]].filter(c => c[1]);
|
||||
$('#view').innerHTML = `<div class="wrap fmwrap"><header class="homehead"><div><h1>${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}</h1><p class="sub">${today}</p></div><div class="homeact">${inboxBell()}</div></header>
|
||||
<div id="annc"></div><div class="fmgrid">${cards.map(c => c[2]).join('') || '<p class="empty">Nothing here yet. An admin can turn on Calendar, Chores, Notes, Chat and more for you.</p>'}</div></div>`;
|
||||
inboxWire();
|
||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||
const done = new Set(cards.map(c => c[0])), run = (k, f) => { if (done.has(k)) f().catch(e => fmErr({ cal: 'fmcal', chores: 'fmch', shop: 'fmsh', notes: 'fmnt', chat: 'fmct', tasks: 'fmtk' }[k], e)); };
|
||||
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
|
||||
}
|
||||
async function fmEvents() {
|
||||
const d0 = new Date(), t0 = crYmd(d0), t1 = crShift(t0, 1), from = new Date(t0 + 'T00:00:00').getTime(), to = new Date(t1 + 'T23:59:59').getTime();
|
||||
const r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
|
||||
const day = (ymdS, e) => e.allDay ? e.start <= ymdS && e.end > ymdS : crYmd(new Date(e.start)) === ymdS;
|
||||
const section = (label, ymdS) => { const l = r.events.filter(e => day(ymdS, e)).sort((a, c) => (c.allDay - a.allDay) || (a.allDay ? 0 : a.start - c.start)); return `<div class="fmday"><small style="display:block">${label}</small>${l.map(e => `<div class="fmrow"><span class="fmdot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="grow"><b>${esc(e.title)}</b></span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || '<small class="sub" style="display:block;text-transform:none;letter-spacing:0;font-size:13px;margin:4px 0">Nothing</small>'}</div>`; };
|
||||
b.innerHTML = section('Today', t0) + section('Tomorrow', t1);
|
||||
}
|
||||
async function fmChores() {
|
||||
const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id;
|
||||
const per = d.people.map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null);
|
||||
const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done);
|
||||
b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'}
|
||||
${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`;
|
||||
b.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: crYmd(new Date()), user: c.dataset.u || undefined }); toast('Nice work!'); fmChores(); } catch (e) { toast(e.message); c.checked = false; } });
|
||||
}
|
||||
async function fmShopping() {
|
||||
const b0 = fmBody('fmsh'); const st = await jfetch('/api/recipes/status'); if (!st.enabled) { if (b0) b0.innerHTML = '<p class="sub" style="margin:6px 0">The shopping list is not set up yet.</p>'; return; }
|
||||
const l = await jfetch('/api/recipes/shopping'), b = fmBody('fmsh'); if (!b) return; const open = l.items.filter(i => !i.checked);
|
||||
b.innerHTML = `${open.slice(0, 8).map(i => `<label class="critem"><input type="checkbox" data-id="${esc(i.id)}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty" style="margin:6px 0">Nothing to buy.</p>'}${open.length > 8 ? `<small class="sub">+ ${open.length - 8} more</small>` : ''}
|
||||
<form class="bar" id="fmshf" style="margin:8px 0 0;gap:8px;flex-wrap:nowrap"><input type="text" id="fmshi" placeholder="Add an item" maxlength="200" autocomplete="off" style="flex:1"><button class="btn ghost">Add</button></form>`;
|
||||
b.querySelectorAll('input[data-id]').forEach(c => c.onchange = async () => { try { await jfetch('/api/recipes/shopping/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.dataset.id, checked: true }) }); fmShopping(); } catch (e) { toast(e.message); c.checked = false; } });
|
||||
b.querySelector('#fmshf').onsubmit = async e => { e.preventDefault(); const i = b.querySelector('#fmshi'), t = i.value.trim(); if (!t) return; try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: t }) }); fmShopping(); } catch (x) { toast(x.message); } };
|
||||
}
|
||||
async function fmNotes() {
|
||||
const r = await jfetch('/api/notes'), b = fmBody('fmnt'); if (!b) return; const l = r.notes.filter(n => !n.archived && n.share !== 'me').slice(0, 4);
|
||||
b.innerHTML = l.map(n => `<a class="fmrow" href="#/notes/${esc(n.id)}" style="text-decoration:none;color:inherit"><span class="fmdot" style="background:${NT_COLORS[n.color] || 'var(--line)'}"></span><span class="grow"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? ' 📌' : ''}</b><small>${n.type === 'list' ? ntDone(n) + ' of ' + n.items.length + ' done' + (n.items.find(i => !i.done) ? ' · next: ' + esc(n.items.find(i => !i.done).text) : '') : esc(n.body.slice(0, 70))}</small></span></a>`).join('') || '<p class="empty" style="margin:6px 0">No shared notes yet.</p>';
|
||||
}
|
||||
async function fmChat() {
|
||||
const r = await jfetch('/api/chat/convs'), b = fmBody('fmct'); if (!b) return; const l = r.convs.filter(c => !c.archived).slice(0, 4);
|
||||
b.innerHTML = l.map(c => `<a class="fmrow" href="#/chat/${encodeURIComponent(c.id)}" style="text-decoration:none;color:inherit"><span class="sgav" style="background:${sgColor(c.id)};width:30px;height:30px;font-size:13px">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b><small>${esc(c.preview || '')}</small></span>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</a>`).join('') || '<p class="empty" style="margin:6px 0">No chats yet.</p>';
|
||||
}
|
||||
async function fmTasks() {
|
||||
const r = await jfetch('/api/tasks/upcoming?today=' + crYmd(new Date())), b = fmBody('fmtk'); if (!b) return;
|
||||
b.innerHTML = r.tasks.slice(0, 5).map(t => `<a class="fmrow" href="#/tasks" style="text-decoration:none;color:inherit"><span class="grow"><b>${esc(t.title)}</b></span><small style="${t.due < crYmd(new Date()) ? 'color:var(--danger)' : ''}">${t.due === crYmd(new Date()) ? 'Today' : esc(t.due)}</small></a>`).join('') || '<p class="empty" style="margin:6px 0">Nothing due this week. 🎉</p>';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
|
||||
let inboxN = 0;
|
||||
const inboxBell = () => `<button class="btn ghost inboxbell" id="inboxbtn" aria-label="Notifications" title="Notifications">${icon('bell')}<i class="nbadge" id="inboxn" ${inboxN ? '' : 'hidden'}>${inboxN > 99 ? '99+' : inboxN}</i></button>`;
|
||||
function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); }
|
||||
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
||||
async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} }
|
||||
setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && document.getElementById('inboxbtn')) inboxCount(); }, 45000);
|
||||
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
||||
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
|
||||
async function inboxOpen() {
|
||||
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
|
||||
let items = [];
|
||||
const draw = () => {
|
||||
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
|
||||
l.innerHTML = items.length ? `<div class="list">${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `<a class="row" href="${esc(i.url || '#/home')}" data-id="${esc(i.id)}" style="${i.read ? 'opacity:.62' : ''}"><span class="ic" style="background:${col}">${icon(ic)}</span><span class="grow"><b>${esc(i.title)}${i.count > 1 ? ` <small>(${i.count})</small>` : ''}</b><small style="white-space:normal">${esc(i.body)}</small></span><small>${inboxAgo(i.at)}</small>${i.read ? '' : '<i class="nbadge" style="position:static;min-width:10px;height:10px;padding:0"></i>'}</a>`; }).join('')}</div>` : '<p class="empty">Nothing yet. Messages, reminders and announcements will show up here.</p>';
|
||||
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); }));
|
||||
};
|
||||
try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); }
|
||||
draw();
|
||||
const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) });
|
||||
el.querySelector('#inboxall').onclick = async () => { try { const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#inboxclr').onclick = async () => { try { await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
}
|
||||
@@ -94,11 +94,14 @@
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/chat-view.js"></script>
|
||||
<script src="/notes-view.js"></script>
|
||||
<script src="/chores-view.js"></script>
|
||||
<script src="/family-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
<script src="/rows.js"></script>
|
||||
<script src="/keys.js"></script>
|
||||
<script src="/inbox-view.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -50,12 +50,13 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
|
||||
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
|
||||
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!here()) return;
|
||||
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
|
||||
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
|
||||
const card = b => lvCard(b);
|
||||
v.innerHTML = `<div class="wrap">${host ? '' : `<div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>`}
|
||||
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
|
||||
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
|
||||
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div class="seg" id="lvgenres" aria-label="Genres"></div><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
|
||||
const known = new Map(lvShelf.map(b => [b.id, b]));
|
||||
const gchips = () => { const el = $('#lvgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList.filter(g => g.count === undefined || g.count > 0)].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
|
||||
const draw = () => {
|
||||
const mine = lvRecent();
|
||||
$('#lvbody').innerHTML = (!S.q && mine.length ? `<h2>My free audiobooks</h2><div class="vgrid">${mine.map(card).join('')}</div>` : '') +
|
||||
@@ -66,7 +67,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
|
||||
};
|
||||
const run = async reset => {
|
||||
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#lvnote').innerHTML = ''; draw();
|
||||
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
|
||||
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; if (r.building && !r.items.length) { $('#lvnote').innerHTML = '<p class="sub">Sorting the audiobooks by genre for the first time. This takes about a minute…</p>'; setTimeout(() => { if (my === S.seq && S.genre) run(true); }, 8000); } r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
|
||||
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
if (my === S.seq) { S.busy = false; draw(); }
|
||||
};
|
||||
|
||||
+6
-5
@@ -154,14 +154,15 @@ async function rdMobiOpen(u8) {
|
||||
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
|
||||
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
|
||||
let sources, mine;
|
||||
try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!here()) return;
|
||||
const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id]));
|
||||
const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 };
|
||||
const S = { q: '', source: 'all', genre: '', page: 1, items: [], more: false, seq: 0 };
|
||||
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop">${host ? '' : `<a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1>`}<span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
|
||||
<p class="sub">Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.</p>
|
||||
<input type="search" id="rdsq" placeholder="Search titles and authors" autocomplete="off" autocapitalize="off" style="width:100%">
|
||||
<div class="seg" id="rdchips"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
|
||||
<div class="seg" id="rdchips"></div><div class="seg" id="rdgenres" aria-label="Genres"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
|
||||
const gchips = () => { const el = $('#rdgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
|
||||
const chips = () => { $('#rdchips').innerHTML = [{ id: 'all', name: 'All' }, ...sources].map(x => `<a href="#" data-s="${esc(x.id)}" class="${S.source === x.id ? 'on' : ''}">${esc(x.name)}</a>`).join(''); $('#rdchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.source = a.dataset.s; chips(); run(true); }); };
|
||||
const draw = () => {
|
||||
const res = $('#rdres'); if (!res) return;
|
||||
@@ -174,7 +175,7 @@ async function rdStore(host) { // host: { el, hash } when shown inside the Store
|
||||
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++;
|
||||
S.busy = true; $('#rdnote').innerHTML = ''; draw();
|
||||
try {
|
||||
const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&page=${S.page}`); if (my !== S.seq) return;
|
||||
const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return;
|
||||
S.items = S.items.concat(r.items); S.more = r.more;
|
||||
const errs = Object.values(r.errors || {}).map(esc);
|
||||
$('#rdnote').innerHTML = errs.length ? `<p class="sub" style="color:var(--danger)">${errs.join('<br>')}</p>` : '';
|
||||
@@ -212,7 +213,7 @@ async function rdStore(host) { // host: { el, hash } when shown inside the Store
|
||||
};
|
||||
list();
|
||||
};
|
||||
chips();
|
||||
chips(); gchips();
|
||||
let t; $('#rdsq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
|
||||
$('#rdsq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
|
||||
if ($('#rdcat')) $('#rdcat').onclick = catalogues;
|
||||
|
||||
+14
-4
@@ -45,7 +45,7 @@ async function searchBooks(term) {
|
||||
}
|
||||
|
||||
function viewSearch() {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, chat, notes, tasks, events, files, music, recipes, contacts, books…" autocomplete="off" autocapitalize="off">
|
||||
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
||||
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
||||
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
||||
@@ -74,7 +74,7 @@ function viewSearch() {
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
||||
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
||||
const [libs, files, music, video, pods, books, ddg, hub, mailR] = await Promise.allSettled([
|
||||
const [libs, files, music, video, pods, books, ddg, hub, mailR, myBooks] = await Promise.allSettled([
|
||||
libsP,
|
||||
libsP.then(l => searchFiles(term, l)),
|
||||
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
||||
@@ -84,6 +84,7 @@ function viewSearch() {
|
||||
jfetch('/api/ddg?q=' + enc(term)),
|
||||
jfetch('/api/search?q=' + enc(term)),
|
||||
allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')),
|
||||
allowed('read') ? jfetch('/api/read/list') : Promise.reject(new Error('not allowed')),
|
||||
]);
|
||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
||||
@@ -112,6 +113,15 @@ function viewSearch() {
|
||||
${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>` : '';
|
||||
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
|
||||
// the family parts: notes, chat, contacts, chores, recipes, and the books on your shelf
|
||||
const row = (href, color, ic, title, sub) => `<a class="row" href="${href}"><span class="ic" style="background:${color}">${icon(ic)}</span><span class="grow"><b>${esc(title)}</b><small style="white-space:normal">${esc(sub)}</small></span></a>`;
|
||||
const myBookHtml = (myBooks.status === 'fulfilled' && Array.isArray(myBooks.value) ? myBooks.value : []).filter(b => ((b.title || '') + ' ' + (b.author || '')).toLowerCase().includes(lower)).slice(0, 8).map(b => row('#/read/open/' + enc(b.id), '#2b8a5e', 'book', b.title, b.author || 'Your books')).join('');
|
||||
const famHtml = sec('Notes and lists', (h.notes || []).map(n => row('#/notes/' + enc(n.id), '#e5b800', n.type === 'list' ? 'check' : 'doc', n.title, n.share + (n.snippet ? ' · ' + n.snippet : ''))).join(''))
|
||||
+ sec('Chat', (h.chat || []).map(c => row('#/chat/' + enc(c.conv), '#2aa6a0', 'chat', c.convName, c.msg.name + ': ' + c.msg.text)).join(''))
|
||||
+ sec('Contacts', (h.contacts || []).map(c => row('#/contacts/c/' + enc(c.id), '#5b8def', 'people', c.name, [c.org, c.email, c.phone].filter(Boolean).join(' · '))).join(''))
|
||||
+ sec('Chores', (h.chores || []).map(c => row('#/chores', '#2f9e6e', 'check', c.title, [c.repeat, c.who, c.points ? c.points + ' pts' : ''].filter(Boolean).join(' · '))).join(''))
|
||||
+ sec('Recipes', (h.recipes || []).map(r => row('#/recipes/r/' + enc(r.slug), '#e0742b', 'chef', r.name, r.minutes ? r.minutes + ' min' : 'Recipe')).join(''))
|
||||
+ sec('Your books', myBookHtml);
|
||||
evStore.length = 0;
|
||||
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
|
||||
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).join('');
|
||||
@@ -130,8 +140,8 @@ function viewSearch() {
|
||||
});
|
||||
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
|
||||
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
|
||||
res.innerHTML = card + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
|
||||
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
||||
res.innerHTML = card + famHtml + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
|
||||
if (!famHtml && !taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
||||
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
|
||||
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
|
||||
|
||||
+13
-2
@@ -43,7 +43,13 @@ async function addPersonSheet() {
|
||||
function personSheet(u) {
|
||||
const self = u.id === ME.id, admin = u.role === 'admin';
|
||||
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
|
||||
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
|
||||
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}
|
||||
<h2 style="margin-top:14px">Child account</h2><label class="row" style="cursor:pointer"><span class="grow"><b>This is a child</b><small style="white-space:normal">Adds a daily time limit and a bedtime, and a safer Store and podcast search.</small></span><input type="checkbox" id="kon"${u.kid && u.kid.on ? ' checked' : ''}></label>
|
||||
<div id="kopt" ${u.kid && u.kid.on ? '' : 'hidden'}><label for="klim">Time per day, in minutes (empty for no limit)</label><input type="number" id="klim" min="0" max="720" value="${u.kid && u.kid.limitMin ? u.kid.limitMin : ''}" placeholder="120">
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Bedtime</b><small>The app is locked in these hours</small></span><input type="checkbox" id="kbo"${u.kid && u.kid.bedtime && u.kid.bedtime.on ? ' checked' : ''}></label>
|
||||
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="kbf">From</label><input type="time" id="kbf" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.from) || '20:30')}"></div><div style="flex:1"><label for="kbt">To</label><input type="time" id="kbt" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.to) || '07:00')}"></div></div>
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Safer Store and podcast search</b><small style="white-space:normal">Books and audiobooks from a few kid-friendly sources and genres, and no podcasts marked explicit</small></span><input type="checkbox" id="ksf"${!u.kid || u.kid.safe !== false ? ' checked' : ''}></label>
|
||||
<div class="row" id="kuse"><span class="grow"><small>Today</small><b id="kuset">…</b></span><button type="button" class="btn ghost" data-m="15">+15 min</button><button type="button" class="btn ghost" data-m="30">+30 min</button><button type="button" class="btn ghost" data-m="60">+1 h</button></div></div>`}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
|
||||
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
|
||||
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
|
||||
@@ -54,7 +60,12 @@ function personSheet(u) {
|
||||
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
||||
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
|
||||
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
|
||||
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
|
||||
const kon = el.querySelector('#kon'); if (kon) {
|
||||
const fillUse = async () => { try { const r = await jfetch('/api/kid/usage?user=' + encodeURIComponent(u.id)); el.querySelector('#kuset').textContent = r.on ? r.used + ' min used' + (r.limit ? ' of ' + r.limit : ' (no limit)') + (r.locked ? ' · locked: ' + (r.locked === 'bedtime' ? 'bedtime' : 'time is up') : '') : 'Not saved as a child account yet'; } catch { el.querySelector('#kuset').textContent = ''; } };
|
||||
kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse();
|
||||
el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } });
|
||||
}
|
||||
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); };
|
||||
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
|
||||
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
|
||||
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
|
||||
|
||||
@@ -1035,3 +1035,24 @@ body.vizopen { overflow: hidden; }
|
||||
/* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */
|
||||
@media (min-width: 768px) and (max-width: 1023px) and (max-height: 1100px) { #nav a > span:not(.nbadge), #nav .ngrp span { display: none; } #nav a { min-height: 34px; justify-content: center; } #nav a svg { width: 22px; height: 22px; } #nav .ngrp { margin-top: 3px; padding-top: 3px; } #nav { gap: 1px; } }
|
||||
@media (min-width: 1024px) and (max-height: 1100px) { body.nav-mini #nav a > span:not(.nbadge), body.nav-mini #nav .ngrp span { display: none; } body.nav-mini #nav a { min-height: 34px; justify-content: center; } body.nav-mini #nav a svg { width: 22px; height: 22px; } body.nav-mini #nav { gap: 1px; } }
|
||||
|
||||
/* Chores */
|
||||
.crcard { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 4px 12px; } .crgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } .crgrid .crperson { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px; }
|
||||
.critem { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); cursor: pointer; } .critem:last-child { border-bottom: 0; } .critem input { width: 24px; height: 24px; flex: none; } .critem small { display: block; color: var(--muted); } .critem.done b { text-decoration: line-through; color: var(--muted); } .critem input:disabled { opacity: .5; }
|
||||
.crpts { font-style: normal; font-size: 12px; padding: 3px 8px; border-radius: 99px; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); white-space: nowrap; }
|
||||
.crph { display: flex; align-items: center; gap: 10px; padding: 4px 0; } .crph small { margin-left: auto; color: var(--muted); } .crbar { height: 6px; border-radius: 3px; background: var(--panel2); overflow: hidden; margin: 2px 0 4px; } .crbar i { display: block; height: 100%; background: var(--accent); }
|
||||
.crrank { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } .crrank:last-child { border-bottom: 0; } .crrank .num { width: 22px; color: var(--muted); }
|
||||
|
||||
/* Family page */
|
||||
.fmgrid { display: grid; grid-template-columns: 1fr; gap: 14px; } @media (min-width: 760px) { .fmgrid { grid-template-columns: 1fr 1fr; } } @media (min-width: 1200px) { .fmgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.fmcard { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; min-width: 0; } .fmh { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .fmh h2 { margin: 0 0 6px; } .fmmore { font-size: 13px; color: var(--accent); }
|
||||
.fmrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); } .fmrow:last-child { border-bottom: 0; } .fmrow small { color: var(--muted); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fmrow b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fmdot { width: 10px; height: 10px; border-radius: 50%; flex: none; } .fmday { margin-bottom: 8px; } .fmday > small { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
|
||||
.fmprog { margin-top: 10px; display: grid; gap: 8px; } .fmpr { display: flex; gap: 10px; align-items: center; } .fmpr .grow { min-width: 0; } .fmpr small { color: var(--muted); }
|
||||
|
||||
/* Child account: time left and the lock screen */
|
||||
#kidchip { position: fixed; right: 12px; bottom: calc(12px + var(--ph, 0px)); z-index: 40; padding: 6px 12px; border-radius: 99px; background: var(--panel2); border: 1px solid var(--line); font-size: 13px; box-shadow: 0 2px 10px rgba(0,0,0,.25); } #kidchip.low { background: var(--danger); color: #fff; border-color: transparent; }
|
||||
#kidlock { position: fixed; inset: 0; z-index: 999; background: var(--bg); display: grid; place-items: center; padding: 24px; text-align: center; } .kidbox { max-width: 420px; } .kidbox h1 { margin: 8px 0; }
|
||||
|
||||
.inboxbell { position: relative; }
|
||||
.inboxbell .nbadge { position: absolute; top: -6px; right: -6px; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v64';
|
||||
const CACHE = 'hub-v70';
|
||||
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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user