Chores, Family page, genre search, child accounts, wider Search and Notifications center
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:48:55 -04:00
1 parent cf292e3bc7
commit 0383da87f5
38 files changed
+720 -79

No files matched your search

+6 -5
View File
@@ -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;