Compare commits
19
Commits
video-restart
...
main
No files matched your search
@@ -30,6 +30,8 @@ jobs:
|
|||||||
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
||||||
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
|
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
|
||||||
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
||||||
|
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||||
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||||
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function direct(path, init = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listening positions kept by the hub (direct mode)
|
// Listening positions kept by the hub (direct mode)
|
||||||
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
|
const prog = require('./progress')('audiobook-progress.json', 'audiobook'), progLoad = prog.load, progMerge = prog.merge;
|
||||||
|
|
||||||
async function upstream(me, path, init = {}, retry = true) {
|
async function upstream(me, path, init = {}, retry = true) {
|
||||||
if (OA) return direct(path, init);
|
if (OA) return direct(path, init);
|
||||||
|
|||||||
@@ -1,13 +1,61 @@
|
|||||||
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
|
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
|
||||||
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
|
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
|
||||||
const { spawn } = require('child_process');
|
// Restore (admin): upload that .tar.gz; it is checked, unpacked beside the data, and each top-level item is swapped in with the old one kept in .pre-restore-<date>; then the hub restarts.
|
||||||
const path = require('path');
|
// Export (anyone): your own data as a .tar.gz, without sign-in tokens.
|
||||||
|
const { spawn, execFile } = require('child_process');
|
||||||
|
const fs = require('fs'), path = require('path'), { pipeline } = require('stream/promises');
|
||||||
|
const users = require('./users');
|
||||||
const audit = require('./audit');
|
const audit = require('./audit');
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
|
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
|
||||||
|
const MAX_RESTORE = 4 * 1024 ** 3;
|
||||||
|
const send = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const run = (cmd, args) => new Promise((ok, no) => execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024 }, (e, out, err) => e ? no(new Error((String(err) || e.message).trim().slice(0, 300))) : ok(String(out))));
|
||||||
|
const rm = p => fs.rmSync(p, { recursive: true, force: true });
|
||||||
|
/** Refuse an archive that could write outside the data folder or plant links: every name must stay inside, and only plain files and folders are allowed. */
|
||||||
|
async function checkArchive(file) {
|
||||||
|
const names = (await run('tar', ['-tzf', file])).split('\n').filter(Boolean), long = (await run('tar', ['-tvzf', file])).split('\n').filter(Boolean);
|
||||||
|
if (!names.length) throw new Error('That backup is empty');
|
||||||
|
for (const n of names) if (path.isAbsolute(n) || n.split('/').includes('..')) throw new Error('That is not a backup this hub made (unsafe file names)');
|
||||||
|
for (const l of long) if (!/^[-d]/.test(l)) throw new Error('That is not a backup this hub made (it contains links or special files)');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
async function restore(req, res, who) {
|
||||||
|
if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' });
|
||||||
|
const stage = path.join(DATA_DIR, '.restore'), up = stage + '.tar.gz', out = path.join(stage, 'files'); rm(stage); rm(up);
|
||||||
|
try {
|
||||||
|
if ((+req.headers['content-length'] || 0) > MAX_RESTORE) return send(res, 413, { error: 'That file is too large' });
|
||||||
|
let n = 0; const cap = new (require('stream').Transform)({ transform(c, _e, cb) { n += c.length; cb(n > MAX_RESTORE ? Object.assign(new Error('That file is too large'), { status: 413 }) : null, c); } });
|
||||||
|
await pipeline(req, cap, fs.createWriteStream(up, { mode: 0o600 }));
|
||||||
|
await checkArchive(up); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); await run('tar', ['-xzf', up, '--no-same-owner', '--no-same-permissions', '-C', out]);
|
||||||
|
const items = fs.readdirSync(out); if (!items.includes('secret.json') && !items.includes('users')) throw new Error('That does not look like an NE Hub backup (no accounts or secret in it)');
|
||||||
|
const keep = path.join(DATA_DIR, '.pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(keep, { recursive: true, mode: 0o700 });
|
||||||
|
for (const it of items) { const dst = path.join(DATA_DIR, it); if (fs.existsSync(dst)) fs.renameSync(dst, path.join(keep, it)); fs.renameSync(path.join(out, it), dst); }
|
||||||
|
audit.log('backup restored', { who: who || '', items: items.length, kept: path.basename(keep) }, req);
|
||||||
|
send(res, 200, { ok: true, items: items.length, kept: path.basename(keep), restarting: true });
|
||||||
|
if (!process.env.NO_RESTART_AFTER_RESTORE) setTimeout(() => process.exit(0), 800); // the container restarts (restart: unless-stopped) and loads the restored data
|
||||||
|
} catch (e) { if (!res.headersSent) send(res, e.status || 400, { error: e.message }); }
|
||||||
|
finally { rm(stage); rm(up); }
|
||||||
|
}
|
||||||
|
/** Your own data as a .tar.gz: tasks, contacts, settings, Bible notes, your notes and so on. Sign-in tokens for mail, iCloud and Plex are left out. */
|
||||||
|
async function exportMine(req, res) {
|
||||||
|
const me = users.current(); if (!me) return send(res, 401, { error: 'Sign in' });
|
||||||
|
const tmp = fs.mkdtempSync(path.join(DATA_DIR, '.export-')), SECRET = new Set(['mail-accounts.json', 'mail.json', 'icloud.json', 'plex.json', 'push.json']);
|
||||||
|
try {
|
||||||
|
fs.cpSync(users.dirOf(me.id), path.join(tmp, 'me'), { recursive: true, filter: s => !SECRET.has(path.basename(s)) });
|
||||||
|
try { const notes = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'notes.json'), 'utf8')), l = Array.isArray(notes) ? notes : notes.notes || []; fs.writeFileSync(path.join(tmp, 'me', 'my-notes.json'), JSON.stringify(l.filter(n => n.owner === me.id), null, 1)); } catch { /* no notes yet */ }
|
||||||
|
const name = 'nehub-' + String(me.name || 'me').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||||
|
const tar = spawn('tar', ['-czf', '-', '--exclude=*.tmp', '-C', tmp, 'me'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||||
|
tar.stdout.pipe(res); res.on('close', () => { tar.kill(); rm(tmp); }); tar.on('error', () => res.destroy());
|
||||||
|
audit.log('personal export', { who: me.name }, req);
|
||||||
|
} catch (e) { rm(tmp); if (!res.headersSent) send(res, 500, { error: 'Could not make the export: ' + e.message }); }
|
||||||
|
}
|
||||||
function handle(req, res, who) {
|
function handle(req, res, who) {
|
||||||
|
if (new URL(req.url, 'http://x').pathname === '/api/backup/restore') return restore(req, res, who);
|
||||||
|
if (new URL(req.url, 'http://x').pathname === '/api/export') return exportMine(req, res);
|
||||||
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
|
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
|
||||||
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||||
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=./.restore*', '--exclude=./.export-*', '--exclude=./.pre-restore-*', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
|
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
|
||||||
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
|
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
|
||||||
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||||
@@ -15,4 +63,4 @@ function handle(req, res, who) {
|
|||||||
res.on('close', () => tar.kill());
|
res.on('close', () => tar.kill());
|
||||||
audit.log('backup downloaded', { who: who || '' }, req);
|
audit.log('backup downloaded', { who: who || '' }, req);
|
||||||
}
|
}
|
||||||
module.exports = { handle };
|
module.exports = { handle, checkArchive };
|
||||||
@@ -4,7 +4,7 @@ const fs = require('fs'), path = require('path');
|
|||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
|
|
||||||
const file = () => path.join(users.dir(), 'bible.json');
|
const file = () => path.join(users.dir(), 'bible.json');
|
||||||
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
|
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } };
|
||||||
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
||||||
@@ -23,9 +23,33 @@ function mergeMarks(have, sent) {
|
|||||||
}
|
}
|
||||||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
|
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json');
|
||||||
|
const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||||
|
const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id });
|
||||||
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
async function devotion(req, res, p) {
|
||||||
|
const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' });
|
||||||
|
if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me)));
|
||||||
|
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
||||||
|
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' });
|
||||||
|
if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); }
|
||||||
|
if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); }
|
||||||
|
const l = loadPrayers();
|
||||||
|
if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); }
|
||||||
|
const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' });
|
||||||
|
if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); }
|
||||||
|
if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); }
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
||||||
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
|
if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p);
|
||||||
|
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); }
|
||||||
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
|
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
|
||||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
|
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
|
||||||
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
|
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function eventFields(title, b) {
|
|||||||
async function board(day) {
|
async function board(day) {
|
||||||
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
|
||||||
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
|
||||||
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
|
|||||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
|
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
|
||||||
const newId = () => crypto.randomBytes(8).toString('hex');
|
const newId = () => crypto.randomBytes(8).toString('hex');
|
||||||
|
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
|
||||||
|
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'] };
|
||||||
|
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
|
||||||
|
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
|
||||||
|
function cleanShare(s) {
|
||||||
|
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
|
||||||
|
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
|
||||||
|
if (!title) throw fail(400, 'Nothing to share');
|
||||||
|
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
|
||||||
|
if (art && (art.length > 600 || !ART_OK.test(art) || art.includes('..'))) throw fail(400, 'That picture cannot be shared');
|
||||||
|
return { kind: s.kind, title, sub: str(s.sub, 120).trim(), art, link };
|
||||||
|
}
|
||||||
|
const shareLine = s => SHARE[s.kind][0] + ' Shared ' + SHARE[s.kind][1] + ': ' + s.title;
|
||||||
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
|
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
|
||||||
|
|
||||||
// ---- storage (small enough to hold in memory and write whole)
|
// ---- storage (small enough to hold in memory and write whole)
|
||||||
@@ -44,9 +57,10 @@ const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone';
|
|||||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
|
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
|
||||||
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
|
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
|
||||||
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
|
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
|
||||||
|
const textOf = m => [m.text, m.share && m.share.title, m.share && m.share.sub].filter(Boolean).join(' · ');
|
||||||
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
|
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
|
||||||
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
|
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
|
||||||
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.share ? SHARE[m.share.kind][0] + ' ' + m.share.title : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
|
||||||
|
|
||||||
// ---- live updates (server-sent events), one stream per open app
|
// ---- live updates (server-sent events), one stream per open app
|
||||||
const annFile = () => path.join(ROOT(), 'announcement.json');
|
const annFile = () => path.join(ROOT(), 'announcement.json');
|
||||||
@@ -60,7 +74,7 @@ const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.
|
|||||||
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
|
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
|
||||||
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
|
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
|
||||||
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
|
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
|
||||||
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, share: m.deleted ? undefined : m.share, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
|
||||||
/** Someone has received or read the chat up to now: the senders' ticks change. */
|
/** Someone has received or read the chat up to now: the senders' ticks change. */
|
||||||
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
|
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
|
||||||
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
|
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
|
||||||
@@ -78,15 +92,22 @@ const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid =
|
|||||||
function send(me, b) {
|
function send(me, b) {
|
||||||
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
|
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
|
||||||
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
|
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
|
||||||
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
|
const share = b.share ? cleanShare(b.share) : undefined;
|
||||||
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
if (!text && !att.length && !share) throw fail(400, 'Write something or attach a file');
|
||||||
return addMsg(me, c, { text, att, quote });
|
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.share ? q.share.title : q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||||
|
return addMsg(me, c, { text, att, quote, share });
|
||||||
|
}
|
||||||
|
/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */
|
||||||
|
function markRead(uid, c) {
|
||||||
|
if (typeof c === 'string') c = convs().find(x => x.id === c && x.members.includes(uid)); if (!c) return;
|
||||||
|
const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(uid, st);
|
||||||
|
inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id });
|
||||||
}
|
}
|
||||||
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
|
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
|
||||||
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
|
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll, share }) {
|
||||||
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, share, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
|
||||||
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
|
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
|
||||||
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
|
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : share ? shareLine(share) : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
|
||||||
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
|
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
|
||||||
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
|
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
|
||||||
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
|
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
|
||||||
@@ -176,16 +197,16 @@ async function handle(req, res) {
|
|||||||
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
|
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
|
||||||
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
|
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
|
||||||
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
|
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
|
||||||
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
|
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.share = undefined; x.react = {}; saveFiles(); }
|
||||||
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
|
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
|
||||||
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
|
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
|
||||||
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
|
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
|
||||||
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
|
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
|
||||||
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
|
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
|
||||||
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
|
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
|
||||||
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
|
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family', ref: { announce: a.id } }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
|
||||||
}
|
}
|
||||||
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
|
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
|
||||||
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
|
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
|
||||||
@@ -213,11 +234,11 @@ async function handle(req, res) {
|
|||||||
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
|
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
|
||||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
|
||||||
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
|
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
|
||||||
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
|
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, share: x.share, fwd: { name: nameOf(x.from) } }) });
|
||||||
}
|
}
|
||||||
if (p === '/search' && req.method === 'GET') {
|
if (p === '/search' && req.method === 'GET') {
|
||||||
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
|
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
|
||||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||||
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
|
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
|
||||||
}
|
}
|
||||||
return json(res, 404, { error: 'Not found' });
|
return json(res, 404, { error: 'Not found' });
|
||||||
@@ -236,7 +257,7 @@ function askParents(me, text) {
|
|||||||
/** Messages in this person's chats that mention the words. */
|
/** Messages in this person's chats that mention the words. */
|
||||||
function searchFor(me, q) {
|
function searchFor(me, q) {
|
||||||
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
|
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
|
||||||
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && textOf(x).toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
|
||||||
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
|
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
|
||||||
}
|
}
|
||||||
module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
module.exports = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||||
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
|
|||||||
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
|
||||||
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
|
||||||
let cache = null;
|
let cache = null;
|
||||||
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
|
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
|
||||||
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
|
||||||
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
|
||||||
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
|
||||||
@@ -76,7 +76,7 @@ const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a rew
|
|||||||
function week(day) {
|
function week(day) {
|
||||||
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().filter(p => p.kid).map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
|
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().filter(p => p.kid).map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
|
||||||
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (liveKid(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
|
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (liveKid(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
|
||||||
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward), goal: (g => tiers.some(t => t.id === g && forMe(t, e.id)) ? g : null)((d.goals[e.id] || {})[from]) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
|
||||||
return { from, to, tiers, people: out };
|
return { from, to, tiers, people: out };
|
||||||
}
|
}
|
||||||
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; };
|
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; };
|
||||||
@@ -86,7 +86,7 @@ async function handle(req, res) {
|
|||||||
try {
|
try {
|
||||||
let m;
|
let m;
|
||||||
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
|
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
|
||||||
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin, kid: isKid(me) }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
|
||||||
if (p === '/' && req.method === 'POST') {
|
if (p === '/' && req.method === 'POST') {
|
||||||
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
|
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
|
||||||
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
|
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
|
||||||
@@ -116,7 +116,7 @@ async function handle(req, res) {
|
|||||||
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
|
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
|
||||||
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
|
||||||
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
|
||||||
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); save(); return json(res, 200, { ok: true }); }
|
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
|
||||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||||
}
|
}
|
||||||
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
|
||||||
@@ -124,11 +124,22 @@ async function handle(req, res) {
|
|||||||
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
|
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
|
||||||
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
|
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
if (p === '/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for (an admin can choose for a child); no reward clears it
|
||||||
|
const b = JSON.parse(await readBody(req, 2e3) || '{}'), d = db(), uid = admin && b.user ? String(b.user) : me.id;
|
||||||
|
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
||||||
|
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
||||||
|
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
||||||
|
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
|
||||||
|
}
|
||||||
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
|
||||||
return json(res, 404, { error: 'Not found' });
|
return json(res, 404, { error: 'Not found' });
|
||||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
||||||
|
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
||||||
|
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
||||||
|
|
||||||
/** Chores whose name or notes mention the words. */
|
/** Chores whose name or notes mention the words. */
|
||||||
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
|
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
|
||||||
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find };
|
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find, goalNeeded, goalReminded };
|
||||||
+9
-5
@@ -28,7 +28,7 @@ function clean(b, old = {}) {
|
|||||||
const c = {
|
const c = {
|
||||||
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
|
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
|
||||||
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
|
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
|
||||||
birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(),
|
birthday: cleanBirthday(b.birthday), anniversary: cleanBirthday(b.anniversary), gifts: str(b.gifts, 2000), notes: str(b.notes, 2000), updated: Date.now(),
|
||||||
};
|
};
|
||||||
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
|
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
|
||||||
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
|
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
|
||||||
@@ -57,6 +57,7 @@ function parseVcf(text) {
|
|||||||
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
|
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
|
||||||
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
|
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
|
||||||
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
|
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
|
||||||
|
else if (name === 'ANNIVERSARY' || name === 'X-ANNIVERSARY') cur.anniversary = val.replace(/T.*$/, '');
|
||||||
else if (name === 'NOTE') cur.notes = unesc(val);
|
else if (name === 'NOTE') cur.notes = unesc(val);
|
||||||
}
|
}
|
||||||
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
|
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
|
||||||
@@ -66,19 +67,22 @@ function toVcf(l) {
|
|||||||
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
|
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
|
||||||
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
|
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
|
||||||
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
|
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
|
||||||
|
c.anniversary && 'X-ANNIVERSARY:' + c.anniversary.replace(/-/g, '').replace(/^/, c.anniversary.startsWith('--') ? '--' : ''),
|
||||||
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
|
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */
|
/** Birthdays and anniversaries as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Each repeats every year. */
|
||||||
function birthdayEvents(win, uid) {
|
function birthdayEvents(win, uid) {
|
||||||
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
|
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
|
||||||
for (const c of load(uid)) {
|
for (const c of load(uid)) {
|
||||||
if (!c.birthday) continue;
|
for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) {
|
||||||
const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4);
|
const v = c[field]; if (!v) continue;
|
||||||
|
const [mo, d] = v.startsWith('--') ? [v.slice(2, 4), v.slice(5, 7)] : [v.slice(5, 7), v.slice(8, 10)], born = v.startsWith('--') ? 0 : +v.slice(0, 4);
|
||||||
for (let y = y0; y <= y1; y++) {
|
for (let y = y0; y <= y1; y++) {
|
||||||
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
|
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
|
||||||
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
|
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
|
||||||
out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id });
|
out.push({ title: `${c.name}${label}${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: field + ':' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color, editable: false, contactId: c.id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
||||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||||
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
|
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
|
||||||
|
TRANSCRIBE_MODEL: ${TRANSCRIBE_MODEL:-Systran/faster-whisper-base} # smaller and about 3x faster than -small on a CPU-only host; set Systran/faster-whisper-small for better accuracy
|
||||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||||
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
|
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
|
||||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||||
|
|||||||
+11
-11
@@ -46,34 +46,34 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
|||||||
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
||||||
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
||||||
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
||||||
- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry)
|
- [~] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list. Not built: flagging what is already in the pantry.**
|
||||||
- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list
|
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||||
|
|
||||||
## 3. Kids and safety
|
## 3. Kids and safety
|
||||||
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
|
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
|
||||||
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
|
- [x] **Approvals (S):** **Done: requests.js ("Can I have...").** a child asks to add a book, app or extension and a parent approves
|
||||||
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
|
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
|
||||||
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
|
- [x] **Simple mode (S):** **Done: simple-mode.js.** a big-button layout with few tabs for young kids or grandparents
|
||||||
|
|
||||||
## 4. Everyday convenience
|
## 4. Everyday convenience
|
||||||
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
||||||
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
|
- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home
|
||||||
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
||||||
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
||||||
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
||||||
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
|
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
|
||||||
|
|
||||||
## 5. Media
|
## 5. Media
|
||||||
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
||||||
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
|
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
|
||||||
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
|
- [~] **Reading goals (S):** **Done: a yearly goal for audiobooks finished, with a listening streak. Not built: a family-wide challenge, e-book goals, exporting notes and highlights.**
|
||||||
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
||||||
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
|
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
|
||||||
|
|
||||||
## 6. Reliability (the admin's side)
|
## 6. Reliability (the admin's side)
|
||||||
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
||||||
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
||||||
- [ ] **Backup and restore screen (S):** one-click restore, per-person export
|
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
|
||||||
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
||||||
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
||||||
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
||||||
@@ -81,7 +81,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
|||||||
## 7. Platform (later)
|
## 7. Platform (later)
|
||||||
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
||||||
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
||||||
- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person
|
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
|
||||||
|
|
||||||
## Suggested order
|
## Suggested order
|
||||||
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
|
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
|
||||||
|
|||||||
@@ -12,22 +12,34 @@ const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf
|
|||||||
const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } };
|
const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } };
|
||||||
|
|
||||||
/** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */
|
/** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */
|
||||||
function add(uid, { kind = 'info', title, body = '', url = '', tag = '' }) {
|
const mergeRef = (a, b) => !a || !b ? b || a : { ...a, ...b, ...(a.mail && b.mail ? { mail: [...a.mail, ...b.mail].slice(-50) } : {}) };
|
||||||
|
function add(uid, { kind = 'info', title, body = '', url = '', tag = '', ref }) {
|
||||||
if (!users.get(uid)) return; const items = load(uid), now = Date.now();
|
if (!users.get(uid)) return; const items = load(uid), now = Date.now();
|
||||||
const old = tag ? items.find(i => i.tag === tag && !i.read) : null;
|
const old = tag ? items.find(i => i.tag === tag && !i.read) : null;
|
||||||
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; items.sort((a, b) => b.at - a.at); }
|
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; if (ref) old.ref = mergeRef(old.ref, ref); items.sort((a, b) => b.at - a.at); }
|
||||||
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), at: now, read: false, count: 1 });
|
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), ref: ref || undefined, at: now, read: false, count: 1 });
|
||||||
save(uid, items.slice(0, MAX));
|
save(uid, items.slice(0, MAX));
|
||||||
}
|
}
|
||||||
const unread = uid => load(uid).filter(i => !i.read).length;
|
const unread = uid => load(uid).filter(i => !i.read).length;
|
||||||
|
/** The thing itself was dealt with somewhere else (a chat opened, say): its notification is done too. */
|
||||||
|
function markTagRead(uid, tag) { const items = load(uid); let ch = false; for (const i of items) if (i.tag === tag && !i.read) { i.read = true; ch = true; } if (ch) save(uid, items); }
|
||||||
|
/** Reading or clearing a notification also settles what it was about: a chat is marked read, new mail is marked read. (Reminders, requests and the like stay as they are: they still need doing or answering.) */
|
||||||
|
async function settle(uid, items) {
|
||||||
|
const jobs = [];
|
||||||
|
for (const i of items) {
|
||||||
|
const m = /^chat:(.+)$/.exec(i.tag || ''); if (m) { try { require('./chat').markRead(uid, m[1]); } catch (e) { console.warn('[inbox]', e.message); } }
|
||||||
|
if (i.ref && Array.isArray(i.ref.mail) && i.ref.mail.length) jobs.push(require('./mail').markRead(i.ref.mail).catch(() => {}));
|
||||||
|
}
|
||||||
|
await Promise.all(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current();
|
const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current();
|
||||||
try {
|
try {
|
||||||
if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); }
|
if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); }
|
||||||
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); return json(res, 200, { unread: items.filter(i => !i.read).length }); }
|
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id), hit = items.filter(i => !i.read && (b.all || i.id === b.id)); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); await settle(me.id, hit); return json(res, 200, { unread: unread(me.id) }); }
|
||||||
if (p === '/clear' && req.method === 'POST') { save(me.id, []); return json(res, 200, { unread: 0 }); }
|
if (p === '/clear' && req.method === 'POST') { const hit = load(me.id).filter(i => !i.read); save(me.id, []); await settle(me.id, hit); return json(res, 200, { unread: 0 }); }
|
||||||
return json(res, 404, { error: 'Not found' });
|
return json(res, 404, { error: 'Not found' });
|
||||||
} catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
} catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
||||||
}
|
}
|
||||||
module.exports = { handle, add, unread, load };
|
module.exports = { handle, add, unread, load, markTagRead };
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
|
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
|
||||||
const fs = require('fs'), path = require('path');
|
const fs = require('fs'), path = require('path');
|
||||||
const users = require('./users'), genres = require('./genres');
|
const users = require('./users'), genres = require('./genres');
|
||||||
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
|
const prog = require('./progress')('librivox-progress.json', 'audiobook'); // keyed lv:<book id>: seconds into the whole book
|
||||||
|
|
||||||
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
||||||
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
||||||
|
|||||||
@@ -332,11 +332,15 @@ async function handle(req, res) {
|
|||||||
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
||||||
async function newest() {
|
async function newest() {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ acct: a.id, mid: m.id, id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
/** Mark messages read: what a cleared "new mail" notification stands for. refs: [{ a: account id, id: message id }]. */
|
||||||
|
async function markRead(refs) {
|
||||||
|
for (const r of (Array.isArray(refs) ? refs : []).slice(0, 50)) { try { const a = findAccount(r.a); await providerOf(a).update(a, r.id, { read: true }); dropFolderCache(a.id); } catch { /* gone or unreachable: nothing to mark */ } }
|
||||||
|
}
|
||||||
/** For the admin health page: can each connected account be reached right now? */
|
/** For the admin health page: can each connected account be reached right now? */
|
||||||
async function health() {
|
async function health() {
|
||||||
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
||||||
}
|
}
|
||||||
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
module.exports = { markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
|
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
|
||||||
const fs = require('fs'), path = require('path');
|
const fs = require('fs'), path = require('path');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
|
const prog = require('./progress')('podcast-progress.json', 'podcast'); // where each person got to in each episode
|
||||||
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
|
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
|
||||||
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
|
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -3,13 +3,32 @@
|
|||||||
const fs = require('fs'), path = require('path');
|
const fs = require('fs'), path = require('path');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
|
|
||||||
module.exports = name => {
|
// With a `kind` ('podcast' or 'audiobook'), each report also adds the time actually listened to that person's listening log (listening.json): seconds per day per kind, and
|
||||||
|
// the items finished. A jump forward bigger than the time that passed (a seek) does not count.
|
||||||
|
const day = t => { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||||
|
const logFile = () => path.join(users.dir(), 'listening.json');
|
||||||
|
const loadLog = () => { try { const o = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return { days: o.days || {}, finished: o.finished || {} }; } catch { return { days: {}, finished: {} }; } };
|
||||||
|
const stats = () => {
|
||||||
|
const o = loadLog(), now = new Date(), today = day(now), tot = k => Object.values(o.days).reduce((n, d) => n + (d[k] || 0), 0), sum = (a, b) => Object.entries(o.days).filter(([k]) => k >= a && k <= b).reduce((n, [, d]) => n + (d.podcast || 0) + (d.audiobook || 0), 0);
|
||||||
|
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
|
||||||
|
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
|
||||||
|
const year = String(now.getFullYear());
|
||||||
|
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
|
||||||
|
};
|
||||||
|
module.exports = (name, kind) => {
|
||||||
const file = () => path.join(users.dir(), name);
|
const file = () => path.join(users.dir(), name);
|
||||||
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
||||||
const merge = body => {
|
const merge = body => {
|
||||||
const mine = load();
|
const mine = load(), log = kind ? loadLog() : null; let logged = false;
|
||||||
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) {
|
||||||
|
const old = mine[k], n = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||||
|
if (log && old && n.t > old.t) { const wall = (n.t - old.t) / 1000, gain = n.pos - old.pos; if (gain > 0 && gain <= wall * 2 + 5 && wall < 600) { const d = log.days[day(n.t)] = log.days[day(n.t)] || {}; d[kind] = Math.round(((d[kind] || 0) + Math.min(gain, wall + 5)) * 10) / 10; logged = true; } }
|
||||||
|
if (log && n.dur > 60 && n.pos >= n.dur - 30 && !log.finished[k]) { log.finished[k] = { t: n.t || Date.now(), kind }; logged = true; }
|
||||||
|
mine[k] = n;
|
||||||
|
}
|
||||||
|
if (logged) { for (const k of Object.keys(log.days).sort().slice(0, -400)) delete log.days[k]; const lf = logFile(), lt = lf + '.tmp'; fs.writeFileSync(lt, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(lt, lf); }
|
||||||
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||||
};
|
};
|
||||||
return { load, merge };
|
return { load, merge };
|
||||||
};
|
};
|
||||||
|
module.exports.stats = stats;
|
||||||
@@ -367,6 +367,7 @@ function viewHome() {
|
|||||||
inboxWire();
|
inboxWire();
|
||||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||||
if (typeof homeResume === 'function') homeResume($('#resume'));
|
if (typeof homeResume === 'function') homeResume($('#resume'));
|
||||||
|
if (typeof homeListening === 'function') homeListening($('#resume'));
|
||||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
$('#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('/')) || '/');
|
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||||
|
|||||||
@@ -92,15 +92,17 @@ async function viewBooks(args = []) {
|
|||||||
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
||||||
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||||
lvBind(v, () => route());
|
lvBind(v, () => route());
|
||||||
|
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
||||||
}
|
}
|
||||||
|
|
||||||
function abSheet(b) {
|
function abSheet(b) {
|
||||||
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
||||||
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
||||||
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
||||||
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||||
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||||
el.querySelector('#abp').onclick = () => go();
|
el.querySelector('#abp').onclick = () => go();
|
||||||
|
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||||
el.querySelector('#abc').onclick = async () => {
|
el.querySelector('#abc').onclick = async () => {
|
||||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||||
|
|||||||
@@ -68,13 +68,14 @@ async function viewBible(args = []) {
|
|||||||
if (!location.hash.startsWith('#/bible')) return;
|
if (!location.hash.startsWith('#/bible')) return;
|
||||||
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
||||||
if (args[0] === 'marks') return bbMarks();
|
if (args[0] === 'marks') return bbMarks();
|
||||||
|
if (args[0] === 'today' && typeof bbToday === 'function') return bbToday();
|
||||||
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
||||||
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
||||||
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
||||||
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
||||||
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
||||||
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
||||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbtd" aria-label="Devotion: verse for today, reading plan, prayer list" title="Devotion">${icon('heart')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||||
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
||||||
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
||||||
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
||||||
@@ -84,7 +85,7 @@ async function viewBible(args = []) {
|
|||||||
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
||||||
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
||||||
});
|
});
|
||||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbtd').onclick = () => { location.hash = '#/bible/today'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||||
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
||||||
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
||||||
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTim
|
|||||||
const card = (t, body, add, cls) => `<section class="card ${cls || ''}"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
|
const card = (t, body, add, cls) => `<section class="card ${cls || ''}"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
|
||||||
function weekCard(w) {
|
function weekCard(w) {
|
||||||
if (!w || !w.people.length) return none('No points yet');
|
if (!w || !w.people.length) return none('No points yet');
|
||||||
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||||
}
|
}
|
||||||
const none = t => `<p class="none">${t}</p>`;
|
const none = t => `<p class="none">${t}</p>`;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
setInterval(checkForUpdate, 10 * 60e3);
|
setInterval(checkForUpdate, 10 * 60e3);
|
||||||
await loadMe();
|
await loadMe();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
if (typeof themeStart === 'function') themeStart();
|
||||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||||
addEventListener('hashchange', route);
|
addEventListener('hashchange', route);
|
||||||
restorePlayer();
|
restorePlayer();
|
||||||
|
|||||||
+31
-7
@@ -17,6 +17,10 @@ async function chStart() {
|
|||||||
ch.es = new EventSource('/api/chat/events');
|
ch.es = new EventSource('/api/chat/events');
|
||||||
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
|
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
|
||||||
}
|
}
|
||||||
|
// (iOS keeps a suspended app's connection open for minutes, and the server skips the phone push for anyone it thinks is looking: close the stream whenever the app is hidden)
|
||||||
|
const chHide = () => { if (ch.es) { ch.es.close(); ch.es = null; } };
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) chHide(); else chStart(); });
|
||||||
|
window.addEventListener('pagehide', chHide);
|
||||||
let chEvT = null;
|
let chEvT = null;
|
||||||
function chEvent(ev) {
|
function chEvent(ev) {
|
||||||
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
|
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
|
||||||
@@ -58,7 +62,7 @@ function chDrawList() {
|
|||||||
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
|
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
|
||||||
const found = q.length >= 2 && ch.found ? ch.found : [];
|
const found = q.length >= 2 && ch.found ? ch.found : [];
|
||||||
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
|
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
|
||||||
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text || (f.msg.share ? f.msg.share.title : ''))}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||||
|| '';
|
|| '';
|
||||||
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
|
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
|
||||||
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
|
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
|
||||||
@@ -74,6 +78,16 @@ function chPoll(m) {
|
|||||||
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
|
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
|
||||||
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
|
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
|
||||||
}
|
}
|
||||||
|
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
|
||||||
|
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
|
||||||
|
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
|
||||||
|
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
|
||||||
|
function chShareCard(m) {
|
||||||
|
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
|
||||||
|
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
|
||||||
|
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
|
||||||
|
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
||||||
|
}
|
||||||
function chBubble(m, c, prev) {
|
function chBubble(m, c, prev) {
|
||||||
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
|
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
|
||||||
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
||||||
@@ -85,7 +99,7 @@ function chBubble(m, c, prev) {
|
|||||||
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
|
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
|
||||||
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
|
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
|
||||||
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
|
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
|
||||||
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
${m.poll ? chPoll(m) : ''}${m.share && !m.deleted ? chShareCard(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
|
||||||
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
|
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
|
||||||
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
|
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
|
||||||
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
|
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
|
||||||
@@ -135,7 +149,7 @@ function chMessageMenu(m, c) {
|
|||||||
}
|
}
|
||||||
function chDrawReply() {
|
function chDrawReply() {
|
||||||
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
|
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
|
||||||
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || (ch.reply.share && ch.reply.share.title) || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||||
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
|
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
|
||||||
}
|
}
|
||||||
function chDrawTray() {
|
function chDrawTray() {
|
||||||
@@ -216,8 +230,8 @@ function chPaintSub() {
|
|||||||
}
|
}
|
||||||
function chDrawPin() {
|
function chDrawPin() {
|
||||||
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
|
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
|
||||||
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.share && last.share.title) || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
|
||||||
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || (m.share && m.share.title) || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
|
||||||
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
|
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
|
||||||
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
|
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
|
||||||
}
|
}
|
||||||
@@ -321,6 +335,15 @@ async function shareToChat(text) {
|
|||||||
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
|
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
|
||||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
|
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
|
||||||
}
|
}
|
||||||
|
/** From a song, film, book...: pick a chat, add a note if you like, and a share card goes in. card = { kind, title, sub, art, link } (art and link are paths inside the hub). */
|
||||||
|
async function shareMedia(card) {
|
||||||
|
if (!allowed('chat')) return toast('Chat is not turned on for you');
|
||||||
|
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
|
||||||
|
const k = CH_SHARE[card.kind] || ['🔗', 'Shared'];
|
||||||
|
const el = openSheet('Share to Chat', k[0] + ' ' + card.title, `<label for="chsn">Add a note (optional)</label><input type="text" id="chsn" maxlength="300" autocomplete="off" placeholder="You will love this">
|
||||||
|
<div class="list" style="max-height:44dvh;overflow:auto;margin-top:10px">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span>${icon('send')}</button>`).join('') || '<p class="empty">Start a chat first (Chat, then New).</p>'}</div>`);
|
||||||
|
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const text = el.querySelector('#chsn').value, to = b.dataset.t; closeSheet(); try { await chPost('/send', { conv: to, text, share: { kind: card.kind, title: card.title, sub: card.sub || '', art: /^\/(plex|api)\/[^\s"'<>\\]*$/.test(card.art || '') && !card.art.includes('..') ? card.art : '', link: card.link } }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(to); } }); } catch (e) { toast(e.message); } });
|
||||||
|
}
|
||||||
// ---- settings: quiet hours, and the family announcement (admin)
|
// ---- settings: quiet hours, and the family announcement (admin)
|
||||||
async function chSettings() {
|
async function chSettings() {
|
||||||
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
||||||
@@ -345,10 +368,11 @@ async function chAnnounceCard(el) {
|
|||||||
function chFit() {
|
function chFit() {
|
||||||
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
|
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
|
||||||
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
|
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
|
||||||
let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
let bottom = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : window.innerHeight; // (page coordinates: the visible area can be panned down while the keyboard is up)
|
||||||
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
|
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
|
||||||
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
|
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
|
||||||
const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px';
|
const top = m.getBoundingClientRect().top; m.style.height = Math.max(document.body.classList.contains('kb') ? 150 : 260, Math.floor(bottom - top - 10)) + 'px';
|
||||||
}
|
}
|
||||||
|
window.addEventListener('kbfit', () => { chFit(); if (!chMounted() || !chq('.sgm')) return; const v = document.getElementById('view'); if (v) v.scrollTop = 0; const l = chq('#chmsgs'); if (l && document.activeElement && document.activeElement.id === 'chtext') l.scrollTop = l.scrollHeight; }); // (the keyboard came or went: refit, and keep the newest message in sight)
|
||||||
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
||||||
setInterval(chFit, 700);
|
setInterval(chFit, 700);
|
||||||
+18
-3
@@ -13,12 +13,25 @@ const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7)
|
|||||||
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
|
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
|
||||||
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
|
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
|
||||||
function crWeekPerson(p) {
|
function crWeekPerson(p) {
|
||||||
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points);
|
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin);
|
||||||
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick${t.id === p.goal ? ' goal' : ''}" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
|
||||||
${next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
${goal ? `<small class="sub" style="display:block;margin:0 0 4px 52px">🎯 ${p.points >= goal.points ? esc(goal.title) + ': reached! 🎉' : (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
|
||||||
|
${canPick ? `<button type="button" class="btn ghost crgoalbtn" data-goal="${esc(p.id)}">${goal ? 'Change goal' : 'Pick a goal'}</button>` : ''}
|
||||||
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
|
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
|
||||||
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
|
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
|
||||||
}
|
}
|
||||||
|
/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */
|
||||||
|
function crGoalCard() {
|
||||||
|
const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return '';
|
||||||
|
const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date()));
|
||||||
|
return `<div class="crgoal"><span class="crgi">🎯</span><span class="grow"><small>Aiming for</small><b>${t ? esc(t.title) : 'Pick a reward to aim for'}</b>${t && now ? `<small>${p.points >= t.points ? 'You did it! 🎉' : (t.points - p.points) + ' more points this week'}</small>` : ''}</span><button type="button" class="btn ${t ? 'ghost' : ''}" id="crgoal">${t ? 'Change' : 'Pick'}</button></div>`;
|
||||||
|
}
|
||||||
|
function crPickGoal(uid) {
|
||||||
|
const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id));
|
||||||
|
const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `<div class="list">${tiers.map(t => `<button class="row" data-g="${esc(t.id)}"><span class="ic" style="background:var(--accent)">🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span>${p.goal === t.id ? icon('check') : ''}</button>`).join('')}</div>${p.goal ? '<div class="bar"><button class="btn ghost" id="crgno" style="width:100%;justify-content:center">No goal for now</button></div>' : ''}`);
|
||||||
|
const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
|
||||||
|
el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set('');
|
||||||
|
}
|
||||||
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
|
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
|
||||||
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
||||||
async function viewChores() {
|
async function viewChores() {
|
||||||
@@ -31,6 +44,7 @@ async function viewChores() {
|
|||||||
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 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>`; };
|
||||||
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>
|
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>
|
<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>
|
||||||
|
${crGoalCard()}
|
||||||
<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>
|
<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>` : ''}
|
${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>` : ''}
|
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
|
||||||
@@ -43,6 +57,7 @@ async function viewChores() {
|
|||||||
$('#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(); };
|
$('#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(); };
|
||||||
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
|
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
|
||||||
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
|
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
|
||||||
|
if ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal));
|
||||||
if ($('#crman')) $('#crman').onclick = crManage;
|
if ($('#crman')) $('#crman').onclick = crManage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function ctShow(c) {
|
|||||||
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
|
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
|
||||||
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
|
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
|
||||||
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
|
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
|
||||||
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
${line('birthday', esc(ctBday(c.birthday)))}${line('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '<br>'))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
||||||
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
|
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
|
||||||
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
|
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
|
||||||
if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); };
|
if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); };
|
||||||
@@ -63,12 +63,12 @@ function ctShow(c) {
|
|||||||
function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
|
function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
|
||||||
|
|
||||||
function ctEdit(c, seed) {
|
function ctEdit(c, seed) {
|
||||||
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' };
|
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', anniversary: '', gifts: '', notes: '' };
|
||||||
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
|
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
|
||||||
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
|
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
|
||||||
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
|
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
|
||||||
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
|
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
|
||||||
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
<label for="ctan">Anniversary</label><input type="date" id="ctan" value="${esc(x.anniversary && !x.anniversary.startsWith('--') ? x.anniversary : '')}"><label for="ctg">Gift ideas</label><textarea id="ctg" rows="3" placeholder="One idea per line">${esc(x.gifts || '')}</textarea><label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||||
el.querySelector('#ctx').onclick = closeSheet;
|
el.querySelector('#ctx').onclick = closeSheet;
|
||||||
el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); });
|
el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); });
|
||||||
@@ -76,7 +76,8 @@ function ctEdit(c, seed) {
|
|||||||
el.querySelector('#ctf').onsubmit = async e => {
|
el.querySelector('#ctf').onsubmit = async e => {
|
||||||
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
|
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
|
||||||
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
|
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
|
||||||
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value };
|
const keepAnn = x.anniversary && x.anniversary.startsWith('--') && !el.querySelector('#ctan').value;
|
||||||
|
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, anniversary: keepAnn ? x.anniversary : el.querySelector('#ctan').value, gifts: el.querySelector('#ctg').value, notes: el.querySelector('#ctt').value };
|
||||||
try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; }
|
try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; }
|
||||||
};
|
};
|
||||||
el.querySelector('#ctn').focus();
|
el.querySelector('#ctn').focus();
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Devotion (inside Bible): a verse for today, a read-through-the-Bible-in-a-year plan with a check-in each day, and a prayer list the whole family shares.
|
||||||
|
const DV_VERSES = ['JHN 3:16', 'PSA 23:1-3', 'PRO 3:5-6', 'ROM 8:28', 'PHP 4:6-7', 'ISA 41:10', 'JER 29:11', 'MAT 6:33', 'PSA 46:1', 'JOS 1:9', 'ROM 12:2', 'GAL 5:22-23', '1CO 13:4-7', 'EPH 2:8-9', 'PSA 119:105', 'MAT 11:28-30', 'COL 3:23', 'PSA 27:1', '2TI 1:7', 'HEB 11:1', 'PSA 121:1-2', 'MIC 6:8', 'JHN 14:27', 'ROM 5:8', '1JN 4:19', 'PSA 37:4-5', 'LAM 3:22-23', 'ISA 40:31', 'MAT 5:14-16', 'JAS 1:5', '1PE 5:7', 'PSA 34:8', 'DEU 31:6', 'PRO 22:6', 'EPH 6:1-3', '1TH 5:16-18', 'PSA 139:13-14', 'JHN 15:12-13', 'ROM 15:13', 'NUM 6:24-26', 'PSA 103:1-2', 'HEB 13:5-6', 'PRO 16:3', '2CO 12:9', 'JHN 1:1-5', 'PSA 100:4-5', 'MAT 28:19-20', 'ZEP 3:17', 'PSA 51:10', 'COL 3:12-14'];
|
||||||
|
const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const dvDiff = (a, b) => Math.round((new Date(b + 'T12:00:00') - new Date(a + 'T12:00:00')) / 864e5);
|
||||||
|
/** The text of verses v..ve in a chapter, footnote markers left out. */
|
||||||
|
function dvText(book, c, v, ve) {
|
||||||
|
const out = []; for (const b of book.chapters[c - 1]) if (b[0] === 'v' && b[1] >= v && b[1] <= (ve || v)) out.push(b[2].map(p => typeof p === 'string' ? p : Array.isArray(p) ? p[1] : '').join('').replace(/\s+/g, ' ').trim());
|
||||||
|
return out.join(' ');
|
||||||
|
}
|
||||||
|
function dvVerseOfDay() {
|
||||||
|
const now = new Date(), n = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 864e5), m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(DV_VERSES[n % DV_VERSES.length]), book = bbBook(m[1]);
|
||||||
|
return book ? { ref: bbRef(m[1], +m[2], +m[3], m[4] ? +m[4] : 0), text: dvText(book, +m[2], +m[3], m[4] ? +m[4] : 0), link: `#/bible/${m[1]}/${m[2]}/${m[3]}` } : null;
|
||||||
|
}
|
||||||
|
/** Every chapter of the Bible in order, split into 365 daily readings. */
|
||||||
|
function dvReadings(day) {
|
||||||
|
const all = []; for (const b of bb.books) for (let c = 1; c <= b.chapters.length; c++) all.push([b.id, c]);
|
||||||
|
const from = Math.floor(day * all.length / 365), to = Math.floor((day + 1) * all.length / 365); return all.slice(from, to);
|
||||||
|
}
|
||||||
|
async function bbToday() {
|
||||||
|
const v = $('#view'); let plan = bbSt.plan || null, prayers = [], showAnswered = false;
|
||||||
|
try { prayers = await bbApi('/prayers'); } catch {}
|
||||||
|
const draw = () => {
|
||||||
|
const vod = dvVerseOfDay(), today = dvDay(), idx = plan ? dvDiff(plan.start, today) : -1, day = Math.min(364, Math.max(0, idx)), done = new Set(plan ? plan.done : []);
|
||||||
|
let streak = 0; if (plan) for (let d = Math.min(idx, 364) - (done.has(Math.min(idx, 364)) ? 0 : 1); d >= 0 && done.has(d); d--) streak++;
|
||||||
|
const open = prayers.filter(p => !p.answered), answered = prayers.filter(p => p.answered);
|
||||||
|
const prayRow = p => `<div class="row"><span class="grow"><span style="display:block;white-space:normal">${esc(p.text)}</span><small>${esc(p.byName)} · ${new Date(p.answered ? p.answeredAt : p.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${p.answered ? ' · answered' : ''}</small></span><button class="ibtn" data-pa="${p.id}" data-on="${p.answered ? '' : '1'}" aria-label="${p.answered ? 'Mark as still praying' : 'Mark as answered'}" title="${p.answered ? 'Still praying' : 'Answered'}">${icon('check')}</button>${p.mine || (typeof ME !== 'undefined' && ME && ME.role === 'admin') ? `<button class="ibtn" data-pd="${p.id}" aria-label="Remove" title="Remove">${icon('trash')}</button>` : ''}</div>`;
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Devotion</h1><a class="btn ghost" href="#/bible">${icon('bible')}Bible</a></div>
|
||||||
|
${vod ? `<h2>Verse for today</h2><a class="card dvverse" href="${vod.link}"><p>“${esc(vod.text)}”</p><small>${esc(vod.ref)} · BSB</small></a>` : ''}
|
||||||
|
<h2>Reading plan</h2>${plan ? `<div class="card" style="max-width:none"><b>Day ${day + 1} of 365</b><span class="sub"> · ${done.size} day${done.size === 1 ? '' : 's'} done${streak > 1 ? ` · ${streak} in a row` : ''}</span>
|
||||||
|
<div class="dvread">${idx < 0 ? `<p class="sub">Your plan starts ${esc(plan.start)}.</p>` : dvReadings(day).map(([b, c]) => `<a class="btn ghost" href="#/bible/${b}/${c}">${esc(bbBook(b).name)} ${c}</a>`).join('')}</div>
|
||||||
|
${idx >= 0 ? `<div class="bar"><button class="btn${done.has(day) ? ' ghost' : ''}" id="dvck">${icon('check')}${done.has(day) ? 'Done today' : 'Mark today as read'}</button><button class="btn ghost" id="dvstop">Stop plan</button></div>` : ''}</div>`
|
||||||
|
: `<div class="card" style="max-width:none"><p class="sub" style="margin-top:0">Read the whole Bible in a year, a few chapters a day, and check in each day.</p><div class="bar"><button class="btn" id="dvstart">Start today</button></div></div>`}
|
||||||
|
<h2>Family prayer list</h2><form class="bar" id="dvpf" style="flex-wrap:nowrap"><input type="text" id="dvpt" placeholder="Add a prayer request" maxlength="500" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
||||||
|
<div class="list">${open.map(prayRow).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what the family is praying about.</small></span></div>'}</div>
|
||||||
|
${answered.length ? `<div class="bar"><button class="btn ghost" id="dvans">${showAnswered ? 'Hide' : 'Show'} answered (${answered.length})</button></div>${showAnswered ? `<div class="list">${answered.map(prayRow).join('')}</div>` : ''}` : ''}</div>`;
|
||||||
|
const save = async (fn) => { try { await fn(); draw(); } catch (e) { toast(e.message); } };
|
||||||
|
const st = $('#dvstart'); if (st) st.onclick = () => save(async () => { plan = (await bbApi('/plan', bbOpt('POST', { start: dvDay() }))).plan; bbSt.plan = plan; });
|
||||||
|
const sp = $('#dvstop'); if (sp) sp.onclick = () => confirmSheet('Stop the reading plan?', 'Your check-ins are cleared.', 'Stop plan', () => save(async () => { await bbApi('/plan', bbOpt('POST', { stop: true })); plan = bbSt.plan = null; }));
|
||||||
|
const ck = $('#dvck'); if (ck) ck.onclick = () => save(async () => { plan = (await bbApi('/plan/check', bbOpt('POST', { day, on: !done.has(day) }))).plan; bbSt.plan = plan; });
|
||||||
|
$('#dvpf').onsubmit = e => { e.preventDefault(); const t = $('#dvpt').value.trim(); if (t) save(async () => { prayers = await bbApi('/prayers', bbOpt('POST', { text: t })); }); };
|
||||||
|
v.querySelectorAll('[data-pa]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/answer', bbOpt('POST', { id: b.dataset.pa, on: !!b.dataset.on })); }));
|
||||||
|
v.querySelectorAll('[data-pd]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/delete', bbOpt('POST', { id: b.dataset.pd })); }));
|
||||||
|
const an = $('#dvans'); if (an) an.onclick = () => { showAnswered = !showAnswered; draw(); };
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
@@ -16,6 +16,21 @@ function viewFamily() {
|
|||||||
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)); };
|
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);
|
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
|
||||||
}
|
}
|
||||||
|
// ---- Keep the page fresh while it stays open (a tablet on the counter): every minute, and when the app comes back to the front. It waits while a sheet is open or someone is typing an item.
|
||||||
|
const fmMounted = () => !!document.querySelector('#view .fmwrap');
|
||||||
|
function fmRefresh() {
|
||||||
|
if (!fmMounted() || document.hidden) return;
|
||||||
|
const sheet = document.getElementById('sheet'); if (sheet && sheet.classList.contains('on')) return;
|
||||||
|
const a = document.activeElement, typing = document.getElementById('fmshi'); if ((a && a.closest && a.closest('.fmwrap') && /^(INPUT|TEXTAREA)$/.test(a.tagName) && a.type !== 'checkbox') || (typing && typing.value)) return;
|
||||||
|
const h = new Date().getHours(), head = document.querySelector('.fmwrap .homehead');
|
||||||
|
if (head) { const t = head.querySelector('h1'), d = head.querySelector('.sub'), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; if (t) t.textContent = greet + (ME ? ', ' + ME.name.split(' ')[0] : ''); if (d) d.textContent = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
|
||||||
|
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||||
|
if (typeof inboxCount === 'function') inboxCount();
|
||||||
|
for (const [id, load] of [['fmcal', fmEvents], ['fmch', fmChores], ['fmsh', fmShopping], ['fmnt', fmNotes], ['fmct', fmChat], ['fmtk', fmTasks]]) if (document.getElementById(id)) load().catch(() => {}); // (a failed refresh keeps what is already shown)
|
||||||
|
}
|
||||||
|
setInterval(fmRefresh, 60000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) fmRefresh(); });
|
||||||
|
|
||||||
async function fmEvents() {
|
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 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 r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
|
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
|
||||||
let inboxN = 0;
|
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>`;
|
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 inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); if (typeof pushResync === 'function') pushResync(); }
|
||||||
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
||||||
|
/** What a read or cleared notification stands for, on this device: its banner on the lock screen goes, and an announcement stops showing on Home. (The server marks the chats and mail read.) */
|
||||||
|
async function inboxSettled(items, all) {
|
||||||
|
for (const i of items) if (i.ref && i.ref.announce) { try { localStorage.setItem('ch_ann_gone:' + (typeof ME !== 'undefined' && ME ? ME.id : ''), i.ref.announce); } catch {} const a = document.getElementById('annc'); if (a) a.innerHTML = ''; }
|
||||||
|
try { const reg = await navigator.serviceWorker.ready, tags = items.map(i => i.tag).filter(Boolean); for (const n of await reg.getNotifications()) if (all || tags.includes(n.tag)) n.close(); } catch {}
|
||||||
|
}
|
||||||
async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} }
|
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);
|
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 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' }); };
|
||||||
@@ -14,11 +19,11 @@ async function inboxOpen() {
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
|
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.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(); }));
|
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; inboxSettled(items.filter(i => i.id === 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); }
|
try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); }
|
||||||
draw();
|
draw();
|
||||||
const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) });
|
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('#inboxall').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); 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); } };
|
el.querySelector('#inboxclr').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
<script src="/recipes-view.js"></script>
|
<script src="/recipes-view.js"></script>
|
||||||
<script src="/contacts-view.js"></script>
|
<script src="/contacts-view.js"></script>
|
||||||
<script src="/bible-view.js"></script>
|
<script src="/bible-view.js"></script>
|
||||||
|
<script src="/devotion-view.js"></script>
|
||||||
<script src="/save-to-files.js"></script>
|
<script src="/save-to-files.js"></script>
|
||||||
<script src="/vbv-view.js"></script>
|
<script src="/vbv-view.js"></script>
|
||||||
<script src="/lyrics-view.js"></script>
|
<script src="/lyrics-view.js"></script>
|
||||||
@@ -105,6 +106,7 @@
|
|||||||
<script src="/device-ui.js"></script>
|
<script src="/device-ui.js"></script>
|
||||||
<script src="/settings-family.js"></script>
|
<script src="/settings-family.js"></script>
|
||||||
<script src="/simple-mode.js"></script>
|
<script src="/simple-mode.js"></script>
|
||||||
|
<script src="/themes.js"></script>
|
||||||
<script src="/boot.js"></script>
|
<script src="/boot.js"></script>
|
||||||
<script src="/shell.js"></script>
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ async function lvSheet(id, after, known = new Map()) {
|
|||||||
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||||
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||||
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button>${allowed('chat') ? `<button class="btn ghost" id="lvh">${icon('send')}Share</button>` : ''}</div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||||
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||||
el.querySelector('#lvp').onclick = () => go();
|
el.querySelector('#lvp').onclick = () => go();
|
||||||
|
if (el.querySelector('#lvh')) el.querySelector('#lvh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: b.title, sub: b.author || '', link: '#/audio/book/lv:' + id }); }; // (no picture: LibriVox covers live on another site)
|
||||||
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||||
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||||
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function lyrOpen() {
|
|||||||
const t = cur(); if (!t || lyr.open) return;
|
const t = cur(); if (!t || lyr.open) return;
|
||||||
lyr.open = true; document.body.classList.add('lyropen');
|
lyr.open = true; document.body.classList.add('lyropen');
|
||||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||||
document.body.appendChild(el); lyrShell(); lyrReload();
|
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||||
}
|
}
|
||||||
function lyrClose() {
|
function lyrClose() {
|
||||||
|
|||||||
+5
-2
@@ -155,8 +155,9 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
if (kind === 'artist') {
|
if (kind === 'artist') {
|
||||||
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
|
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
|
||||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
||||||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
|
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a>${allowed('chat') ? `<button class="btn ghost" id="ash">${icon('send')}Share</button>` : ''}</div>
|
||||||
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
||||||
|
if ($('#ash')) $('#ash').onclick = () => shareMedia({ kind: 'artist', title: mc.title2 || mc.parentTitle || 'Artist', art: px.art((am.Metadata || [])[0] && am.Metadata[0].thumb, 200), link: '#/music/artist/' + key });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'album' || kind === 'playlist') {
|
if (kind === 'album' || kind === 'playlist') {
|
||||||
@@ -171,7 +172,7 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
||||||
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
||||||
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||||
@@ -182,6 +183,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
||||||
}
|
}
|
||||||
|
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||||
|
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||||
markPlaying();
|
markPlaying();
|
||||||
|
|||||||
+1
-1
@@ -112,7 +112,7 @@ function trackSheet(t, extra) {
|
|||||||
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
|
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
|
||||||
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
|
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
|
||||||
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
|
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
|
||||||
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
|
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareMedia({ kind: 'song', title: t.title, sub: t.artist, art: px.art(t.thumb, 200), link: '#/music/album/' + t.albumKey }) }] : []),
|
||||||
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
||||||
] : []),
|
] : []),
|
||||||
...(extra || []),
|
...(extra || []),
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function pdShow(id) {
|
|||||||
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||||
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||||
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||||
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
${allowed('chat') ? `<button class="ibtn" data-share aria-label="Share to Chat" title="Share to Chat">${icon('send')}</button>` : ''}<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
|
||||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||||
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||||
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
||||||
@@ -131,6 +131,7 @@ async function pdShow(id) {
|
|||||||
document.querySelectorAll('.pdep').forEach(r => {
|
document.querySelectorAll('.pdep').forEach(r => {
|
||||||
const e = byId.get(r.dataset.id);
|
const e = byId.get(r.dataset.id);
|
||||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||||
|
const shb = r.querySelector('[data-share]'); if (shb) shb.onclick = () => shareMedia({ kind: 'podcastep', title: pdF(e, 'title'), sub: pdF(show, 'title'), art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) });
|
||||||
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
|
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
|
||||||
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
|
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
|
||||||
r.querySelector('[data-seen]').onclick = async () => {
|
r.querySelector('[data-seen]').onclick = async () => {
|
||||||
@@ -139,9 +140,10 @@ async function pdShow(id) {
|
|||||||
});
|
});
|
||||||
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
||||||
$('#pdmore').onclick = () => {
|
$('#pdmore').onclick = () => {
|
||||||
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
const el = openSheet(pdF(show, 'title'), '', `<div class="list">${allowed('chat') ? `<button class="row" id="pdsh"><span class="grow"><b>Share to Chat</b></span></button>` : ''}<button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||||
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||||
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
||||||
|
const sh = el.querySelector('#pdsh'); if (sh) sh.onclick = () => { closeSheet(); shareMedia({ kind: 'podcast', title: pdF(show, 'title'), sub: pdF(show, 'author') || '', art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) }); };
|
||||||
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
||||||
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
||||||
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
||||||
|
|||||||
+35
-2
@@ -4,7 +4,7 @@
|
|||||||
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
||||||
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
||||||
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
|
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
|
||||||
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||||
let rcQuery = '', rcPage = 1;
|
let rcQuery = '', rcPage = 1;
|
||||||
|
|
||||||
async function viewRecipes(args = []) {
|
async function viewRecipes(args = []) {
|
||||||
@@ -13,6 +13,7 @@ async function viewRecipes(args = []) {
|
|||||||
try {
|
try {
|
||||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
||||||
if (args[0] === 'shop') return await rcShop();
|
if (args[0] === 'shop') return await rcShop();
|
||||||
|
if (args[0] === 'plan') return await rcPlan(args[1]);
|
||||||
return await rcHome();
|
return await rcHome();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!location.hash.startsWith('#/recipes')) return;
|
if (!location.hash.startsWith('#/recipes')) return;
|
||||||
@@ -59,7 +60,7 @@ async function rcOne(slug) {
|
|||||||
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
||||||
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
||||||
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
||||||
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||||
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
||||||
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
||||||
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
||||||
@@ -77,6 +78,7 @@ async function rcOne(slug) {
|
|||||||
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
||||||
paint();
|
paint();
|
||||||
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
||||||
|
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id);
|
||||||
$('#rcadd').onclick = async e => {
|
$('#rcadd').onclick = async e => {
|
||||||
const b = e.currentTarget; b.disabled = true;
|
const b = e.currentTarget; b.disabled = true;
|
||||||
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
||||||
@@ -102,3 +104,34 @@ async function rcShop() {
|
|||||||
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
|
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
|
||||||
await draw(await rcApi('/shopping'));
|
await draw(await rcApi('/shopping'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Meal plan: a week at a time, shared by the family. Pick a recipe for a day, then add the week's ingredients to the shopping list in one tap.
|
||||||
|
const rcYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const rcMonday = d => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; };
|
||||||
|
const RC_MEALS = [['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner']];
|
||||||
|
async function rcPlan(start) {
|
||||||
|
const mon = start && /^\d{4}-\d{2}-\d{2}$/.test(start) ? rcMonday(new Date(start + 'T12:00:00')) : rcMonday(new Date()), days = [...Array(7)].map((_, i) => { const d = new Date(mon); d.setDate(d.getDate() + i); return d; });
|
||||||
|
const from = rcYmd(days[0]), to = rcYmd(days[6]), plan = await rcApi(`/plan?from=${from}&to=${to}`); if (!location.hash.startsWith('#/recipes')) return;
|
||||||
|
const shift = n => { const d = new Date(mon); d.setDate(d.getDate() + n); return '#/recipes/plan/' + rcYmd(d); }, today = rcYmd(new Date()), count = Object.values(plan).reduce((n, l) => n + l.length, 0);
|
||||||
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${rcTabs('plan')}
|
||||||
|
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
||||||
|
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
|
||||||
|
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
||||||
|
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
||||||
|
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
||||||
|
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button></div></div>`;
|
||||||
|
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||||
|
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
|
||||||
|
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
|
||||||
|
}
|
||||||
|
/** Choose the day and meal for a recipe (from a recipe page), or the recipe for a day (from the plan). */
|
||||||
|
function rcPlanPick(slug, name, rid, date) {
|
||||||
|
const el = openSheet(slug ? 'Plan ' + name : 'Add a meal', '', `<form id="rcpf">${slug ? '' : '<input type="search" id="rcpq" placeholder="Search your recipes" autocomplete="off" aria-label="Search recipes"><div class="list" id="rcpr" style="margin:8px 0"></div>'}
|
||||||
|
<label for="rcpd">Day</label><input type="date" id="rcpd" value="${esc(date || rcYmd(new Date()))}" required><label for="rcpm">Meal</label><select id="rcpm">${RC_MEALS.map(m => `<option value="${m[0]}"${m[0] === 'dinner' ? ' selected' : ''}>${m[1]}</option>`).join('')}</select><div class="err" id="rcpe"></div>
|
||||||
|
<div class="bar">${slug ? '<button class="btn" style="flex:1;justify-content:center">Add to plan</button>' : ''}</div></form>`);
|
||||||
|
const save = async (sl, nm, id) => { try { await rcApi('/plan/add', rcOpt('POST', { date: el.querySelector('#rcpd').value, slug: sl, name: nm, rid: id, meal: el.querySelector('#rcpm').value })); closeSheet(); toast('Added to the meal plan', { label: 'Open', run: () => { location.hash = '#/recipes/plan/' + el.querySelector('#rcpd').value; } }); if (location.hash.startsWith('#/recipes/plan')) rcPlan(el.querySelector('#rcpd').value); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||||
|
el.querySelector('#rcpf').onsubmit = e => { e.preventDefault(); if (slug) save(slug, name, rid); };
|
||||||
|
if (slug) return;
|
||||||
|
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||||
|
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||||
|
}
|
||||||
@@ -3,6 +3,17 @@ const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in wind
|
|||||||
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
|
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
|
||||||
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
|
||||||
|
/** Each time the app opens: if this device has a push subscription, tell the server again (it forgets a device when the push service says it is gone, and the time zone can change). */
|
||||||
|
let pushResynced = false;
|
||||||
|
async function pushResync() {
|
||||||
|
if (pushResynced) return; pushResynced = true;
|
||||||
|
try {
|
||||||
|
if (!remSupported() || Notification.permission !== 'granted') return;
|
||||||
|
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]), s = reg && await reg.pushManager.getSubscription(); if (!s) return;
|
||||||
|
await jfetch('/api/push/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON(), tz: myTz() }) });
|
||||||
|
} catch { /* try again next time the app opens */ }
|
||||||
|
}
|
||||||
|
|
||||||
function remindersHtml() {
|
function remindersHtml() {
|
||||||
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
|
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
|
||||||
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
|
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
|
||||||
|
|||||||
+7
-2
@@ -86,7 +86,7 @@ function viewSettings(args) {
|
|||||||
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>
|
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
|
||||||
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||||
@@ -111,7 +111,8 @@ function viewSettings(args) {
|
|||||||
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
|
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
|
||||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. To restore, choose a backup file below.</p>
|
||||||
|
<div class="bar"><label class="btn ghost" for="bkfile" style="cursor:pointer">Restore from a backup…</label><input type="file" id="bkfile" accept=".gz,.tgz,application/gzip" hidden></div><p class="sub" id="bkmsg">Replaces the hub's accounts and data with the backup's. The current data is kept in a .pre-restore folder, and the hub restarts.</p>` : ''}
|
||||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
remindersInit();
|
remindersInit();
|
||||||
@@ -200,6 +201,10 @@ function viewSettings(args) {
|
|||||||
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
||||||
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
||||||
if (typeof famSettings === 'function') famSettings($('#famset'));
|
if (typeof famSettings === 'function') famSettings($('#famset'));
|
||||||
|
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
|
||||||
|
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
|
||||||
|
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
|
||||||
|
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
|
||||||
$('#invpk').onclick = async () => {
|
$('#invpk').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const { code, minutes } = await authPost('/api/auth/invite');
|
const { code, minutes } = await authPost('/api/auth/invite');
|
||||||
|
|||||||
+27
-2
@@ -5,13 +5,38 @@ function openSheet(title, sub, bodyHtml) {
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.id = 'sheet';
|
el.id = 'sheet';
|
||||||
el.innerHTML = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
el.innerHTML = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||||
<div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
<button type="button" class="ibtn sheetx" aria-label="Close">${icon('x')}</button><div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
el.querySelector('.scrim').onclick = closeSheet;
|
el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
|
||||||
|
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
|
||||||
requestAnimationFrame(() => el.classList.add('on'));
|
requestAnimationFrame(() => el.classList.add('on'));
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
function closeSheet() { document.getElementById('sheet')?.remove(); }
|
function closeSheet() { document.getElementById('sheet')?.remove(); }
|
||||||
|
/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */
|
||||||
|
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) s.remove(); }, 230); }
|
||||||
|
/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */
|
||||||
|
function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
|
||||||
|
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true;
|
||||||
|
const reset = () => { el.style.transition = 'transform .2s ease'; el.style.transform = ''; if (scrim) { scrim.style.transition = 'opacity .2s'; scrim.style.opacity = ''; } setTimeout(() => { el.style.transition = ''; if (scrim) scrim.style.transition = ''; }, 220); };
|
||||||
|
el.addEventListener('touchstart', e => {
|
||||||
|
on = false; dead = true; if (e.touches.length !== 1 || (!always && !matchMedia('(max-width: 767px)').matches)) return;
|
||||||
|
const t = e.target; if (t.closest && t.closest('input, textarea, select, [contenteditable], [data-nodrag]')) return;
|
||||||
|
if (!(handle && t.closest && t.closest(handle))) for (let n = t; n; n = n.parentElement) { if (n.scrollTop > 0) return; if (n === el) break; }
|
||||||
|
y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; t0 = Date.now(); dead = false;
|
||||||
|
}, { passive: true });
|
||||||
|
el.addEventListener('touchmove', e => {
|
||||||
|
if (dead) return; const dy = e.touches[0].clientY - y0, dx = e.touches[0].clientX - x0;
|
||||||
|
if (!on) { if (dy > 10 && dy > Math.abs(dx) * 1.5) { on = true; el.style.transition = 'none'; if (scrim) scrim.style.transition = 'none'; } else if (Math.abs(dx) > 10 || dy < -6) { dead = true; return; } else return; }
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
const d = Math.max(0, dy); el.style.transform = 'translateY(' + d + 'px)'; if (scrim) scrim.style.opacity = String(Math.max(0, 1 - d / 420));
|
||||||
|
}, { passive: false });
|
||||||
|
const end = e => {
|
||||||
|
if (!on) return; on = false; dead = true; const dy = (e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : y0) - y0, fast = dy / Math.max(1, Date.now() - t0) > 0.6;
|
||||||
|
if (e.type !== 'touchcancel' && (dy > 110 || (fast && dy > 30))) { el.style.transition = 'transform .18s ease'; el.style.transform = 'translateY(105%)'; if (scrim) { scrim.style.transition = 'opacity .18s'; scrim.style.opacity = '0'; } setTimeout(close, 170); } else reset();
|
||||||
|
};
|
||||||
|
el.addEventListener('touchend', end); el.addEventListener('touchcancel', end);
|
||||||
|
}
|
||||||
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
|
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
|
||||||
|
|
||||||
function actionSheet(title, sub, actions) {
|
function actionSheet(title, sub, actions) {
|
||||||
|
|||||||
@@ -68,3 +68,22 @@
|
|||||||
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
|
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ---- iPhone keyboard: iOS shrinks only the visual viewport when the keyboard opens and pans the whole page to reveal the field, so the app used to slide up with it.
|
||||||
|
// Pin the shell to what is really visible while typing and tuck the bottom bar away; chat and mail size themselves again on 'kbfit'.
|
||||||
|
(function () {
|
||||||
|
const vv = window.visualViewport; if (!vv || !matchMedia('(pointer: coarse)').matches) return;
|
||||||
|
const root = document.documentElement; let raf = 0;
|
||||||
|
const typing = () => { const a = document.activeElement; return !!a && (a.tagName === 'TEXTAREA' || a.isContentEditable || (a.tagName === 'INPUT' && !/^(checkbox|radio|button|submit|reset|range|file|color|image)$/.test(a.type))); };
|
||||||
|
function apply() {
|
||||||
|
raf = 0;
|
||||||
|
const kb = typing() && window.innerHeight - vv.height > 120 && window.innerWidth < 768;
|
||||||
|
document.body.classList.toggle('kb', kb);
|
||||||
|
if (kb) { root.style.setProperty('--vvh', vv.height + 'px'); root.style.setProperty('--vvt', vv.offsetTop + 'px'); if (window.scrollY) window.scrollTo(0, 0); }
|
||||||
|
else { root.style.removeProperty('--vvh'); root.style.removeProperty('--vvt'); }
|
||||||
|
window.dispatchEvent(new Event('kbfit'));
|
||||||
|
}
|
||||||
|
const soon = () => { if (!raf) raf = requestAnimationFrame(apply); };
|
||||||
|
vv.addEventListener('resize', soon); vv.addEventListener('scroll', soon);
|
||||||
|
document.addEventListener('focusin', soon); document.addEventListener('focusout', () => setTimeout(soon, 80));
|
||||||
|
})();
|
||||||
+23
-1
@@ -123,6 +123,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px
|
|||||||
padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; }
|
padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; }
|
||||||
#sheet.on .scrim { opacity: 1; }
|
#sheet.on .scrim { opacity: 1; }
|
||||||
#sheet.on .sheet { transform: none; }
|
#sheet.on .sheet { transform: none; }
|
||||||
|
#sheet .sheetx { position: sticky; top: 0; float: right; z-index: 2; width: 36px; height: 36px; margin: 0 -6px 0 8px; background: var(--panel2); border-radius: 50%; } #sheet .sheetx svg { width: 18px; height: 18px; }
|
||||||
|
#sheet .sheet { overscroll-behavior: contain; }
|
||||||
#sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; }
|
#sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; }
|
||||||
#sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; }
|
#sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; }
|
||||||
#sheet .list { background: var(--panel2); border: 0; margin-top: 8px; }
|
#sheet .list { background: var(--panel2); border: 0; margin-top: 8px; }
|
||||||
@@ -264,6 +266,14 @@ body { -webkit-touch-callout: none; }
|
|||||||
button, a, .row { -webkit-user-select: none; user-select: none; }
|
button, a, .row { -webkit-user-select: none; user-select: none; }
|
||||||
input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; }
|
input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user-select: text; user-select: text; }
|
||||||
|
|
||||||
|
/* iPhone keyboard (shell.js adds body.kb while a field is focused and the keyboard is up): the shell is exactly the visible area, with no bottom bar in the way */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
body.kb { overflow: hidden; }
|
||||||
|
body.kb #shell { height: var(--vvh, 100dvh); transform: translateY(var(--vvt, 0px)); }
|
||||||
|
body.kb #nav, body.kb #player, body.kb #upd, body.kb #toast { display: none; }
|
||||||
|
body.kb .sgm { min-height: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* "A new version is available" notice */
|
/* "A new version is available" notice */
|
||||||
#upd { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--nav-h) + var(--sab) + 70px); z-index: 30; display: flex; align-items: center; gap: 12px;
|
#upd { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--nav-h) + var(--sab) + 70px); z-index: 30; display: flex; align-items: center; gap: 12px;
|
||||||
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
|
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
|
||||||
@@ -763,7 +773,7 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vdeckc { position: relative; flex: none; }
|
.vdeckc { position: relative; flex: none; }
|
||||||
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
||||||
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
||||||
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
|
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { width: auto; flex: 0 1 auto; min-height: 40px; max-width: 48vw; }
|
||||||
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
||||||
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
||||||
.vdeckc .vcard.wide { width: 250px; }
|
.vdeckc .vcard.wide { width: 250px; }
|
||||||
@@ -1015,6 +1025,9 @@ body.vizopen { overflow: hidden; }
|
|||||||
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
|
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
|
||||||
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
|
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
|
||||||
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
|
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
|
||||||
|
.sgshare { display: flex; align-items: center; gap: 10px; margin: 2px 0 4px; padding: 8px; min-width: 220px; border-radius: 12px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgshare.off { opacity: .75; }
|
||||||
|
.sgshare-art { flex: none; width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: rgba(127,127,127,.25); display: grid; place-items: center; font-size: 26px; font-style: normal; }
|
||||||
|
.sgshare-t { min-width: 0; flex: 1; } .sgshare-t small, .sgshare-t b, .sgshare-t em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: normal; } .sgshare-t small { opacity: .7; } .sgshare-t em { opacity: .8; font-size: .9em; } .sgshare-t .sgshare-off { opacity: 1; color: var(--danger); font-weight: 600; margin-top: 2px; } .sgshare-go { font-size: 22px; opacity: .6; }
|
||||||
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
|
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
|
||||||
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
|
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
|
||||||
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
|
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
|
||||||
@@ -1062,6 +1075,9 @@ body.vizopen { overflow: hidden; }
|
|||||||
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
|
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
|
||||||
|
|
||||||
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
|
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
|
||||||
|
.crtick.goal { width: 4px; background: var(--accent); opacity: 1; }
|
||||||
|
.crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); }
|
||||||
|
.crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; }
|
||||||
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
|
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
|
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
|
||||||
@@ -1079,3 +1095,9 @@ body.simple #shell { flex-direction: column; }
|
|||||||
body.simple #view { padding-bottom: 110px; }
|
body.simple #view { padding-bottom: 110px; }
|
||||||
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
|
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
|
||||||
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
|
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
|
||||||
|
|
||||||
|
.swatches { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } .swatches button { width: 40px; height: 40px; min-height: 0; padding: 0; border-radius: 50%; border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--line); } .swatches button.on { box-shadow: 0 0 0 3px var(--text); }
|
||||||
|
.dvverse { display: block; max-width: none; } .dvverse p { font-family: Georgia, 'Times New Roman', serif; font-size: 19px; line-height: 1.5; margin: 0 0 8px; } .dvverse small { color: var(--muted); } .dvread { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||||
|
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
|
||||||
|
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
||||||
|
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
||||||
+9
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v88';
|
const CACHE = 'hub-v102';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
@@ -59,6 +59,14 @@ self.addEventListener('push', e => {
|
|||||||
let d = {}; try { d = e.data.json(); } catch {}
|
let d = {}; try { d = e.data.json(); } catch {}
|
||||||
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
|
||||||
});
|
});
|
||||||
|
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
|
||||||
|
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
|
||||||
|
try {
|
||||||
|
const k = await (await fetch('/api/push/key', { credentials: 'same-origin' })).json();
|
||||||
|
const s = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey });
|
||||||
|
await fetch('/api/push/subscribe', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON() }) });
|
||||||
|
} catch { /* signed out or offline: the app re-registers when it is next opened */ }
|
||||||
|
})()));
|
||||||
self.addEventListener('notificationclick', e => {
|
self.addEventListener('notificationclick', e => {
|
||||||
e.notification.close();
|
e.notification.close();
|
||||||
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices.
|
||||||
|
const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']];
|
||||||
|
const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']];
|
||||||
|
const THEME_VARS = {
|
||||||
|
light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' },
|
||||||
|
dark: { '--bg': '#0f1115', '--panel': '#181b21', '--panel2': '#20242c', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#272c35' },
|
||||||
|
amoled: { '--bg': '#000', '--panel': '#0b0b0d', '--panel2': '#16171b', '--text': '#eceef2', '--muted': '#8b93a3', '--line': '#1f2127' },
|
||||||
|
};
|
||||||
|
function themeApply() {
|
||||||
|
const root = document.documentElement, mode = store.get('theme_mode', 'auto'), acc = THEME_ACCENTS.find(a => a[0] === store.get('theme_accent', 'blue')) || THEME_ACCENTS[0];
|
||||||
|
const dark = mode === 'dark' || mode === 'amoled' || (mode === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
for (const k of Object.keys(THEME_VARS.light)) { if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); }
|
||||||
|
const hex = dark ? acc[3] : acc[2];
|
||||||
|
if (acc[0] === 'blue' && mode === 'auto') { root.style.removeProperty('--accent'); root.style.removeProperty('--accent-t'); }
|
||||||
|
else { const n = parseInt(hex.slice(1), 16), lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; root.style.setProperty('--accent', hex); root.style.setProperty('--accent-t', lum > .6 ? '#0b1020' : '#fff'); }
|
||||||
|
root.style.colorScheme = mode === 'auto' ? '' : dark ? 'dark' : 'light';
|
||||||
|
const m = document.querySelector('meta[name=theme-color]'); if (m) m.content = dark ? (mode === 'amoled' ? '#000000' : '#0f1115') : (mode === 'light' ? '#f5f6f8' : m.content);
|
||||||
|
}
|
||||||
|
function themeStart() { themeApply(); try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themeApply); } catch {} }
|
||||||
|
function themeSettings(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const draw = () => {
|
||||||
|
const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue');
|
||||||
|
el.innerHTML = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
||||||
|
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||||
|
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
|
||||||
|
el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); });
|
||||||
|
el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); });
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
@@ -28,3 +28,17 @@ async function homeResume(el) {
|
|||||||
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||||
}
|
}
|
||||||
const today = { books: [] };
|
const today = { books: [] };
|
||||||
|
|
||||||
|
// Home: how much you listened this week (podcasts and audiobooks), the day streak, and a yearly goal for audiobooks finished. The goal is kept in your synced settings.
|
||||||
|
const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); };
|
||||||
|
async function homeListening(after) {
|
||||||
|
if (!after || !(allowed('podcast') || allowed('audio'))) return;
|
||||||
|
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
|
||||||
|
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return;
|
||||||
|
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
|
||||||
|
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
|
||||||
|
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
|
||||||
|
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
|
||||||
|
after.insertAdjacentElement('afterend', card);
|
||||||
|
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
|
||||||
|
}
|
||||||
+8
-6
@@ -82,7 +82,7 @@ async function videoHome(st, libKey) {
|
|||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||||
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||||
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||||
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||||
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||||
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||||
@@ -107,21 +107,21 @@ async function videoHome(st, libKey) {
|
|||||||
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
// sort and filter the library below
|
// sort and filter the library below
|
||||||
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
||||||
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
$('#vsort').value = fs; $('#vwatch').value = fu ? '1' : '';
|
||||||
px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||||
px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||||
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||||
let start = 0, total = 0, all = [], gen = 0;
|
let start = 0, total = 0, all = [], gen = 0;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked || $('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||||
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
|
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
|
||||||
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
|
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
|
||||||
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
|
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
|
||||||
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
|
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
|
||||||
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vwatch').value || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vwatch').value = ''; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||||||
};
|
};
|
||||||
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
||||||
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr', '#vwatch']) $(id).onchange = () => reload();
|
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vwatch').onchange = () => { store.set('video_unw', $('#vwatch').value === '1' ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr']) $(id).onchange = () => reload();
|
||||||
reload();
|
reload();
|
||||||
// press and hold (or right-click) a card: quick actions
|
// press and hold (or right-click) a card: quick actions
|
||||||
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
|
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
|
||||||
@@ -170,7 +170,7 @@ async function videoItem(id) {
|
|||||||
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||||
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
${allowed('chat') && m.type !== 'collection' ? `<button class="btn ghost" id="vshare">${icon('send')}Share</button>` : ''}${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||||
<p class="vsum">${esc(m.summary || '')}</p>
|
<p class="vsum">${esc(m.summary || '')}</p>
|
||||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||||
<div id="vkids"></div></div>`;
|
<div id="vkids"></div></div>`;
|
||||||
@@ -196,6 +196,8 @@ async function videoItem(id) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||||
|
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||||
|
if ($('#vshare')) $('#vshare').onclick = () => shareMedia({ kind: m.type === 'movie' ? 'movie' : m.type === 'episode' ? 'episode' : 'show', title: m.type === 'episode' ? m.title : m.title, sub: m.type === 'episode' ? [m.grandparentTitle, 'S' + m.parentIndex + 'E' + m.index].filter(Boolean).join(' · ') : m.type === 'season' ? m.parentTitle || '' : m.year ? String(m.year) : '', art: vdArt(m.thumb, 200, 300), link: '#/video/item/' + id });
|
||||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function vizOpen() {
|
|||||||
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
|
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
|
||||||
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
|
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
|
||||||
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
|
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
|
||||||
document.body.appendChild(el); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
document.body.appendChild(el); swipeToDismiss(el, vizClose, { handle: '.viztop', always: true }); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||||
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
|
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
|
||||||
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
|
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
|
||||||
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
|
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const tasks = require('./tasks');
|
|||||||
const cal = require('./cal');
|
const cal = require('./cal');
|
||||||
const mail = require('./mail');
|
const mail = require('./mail');
|
||||||
const inbox = require('./inbox');
|
const inbox = require('./inbox');
|
||||||
|
const chores = require('./chores');
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||||
@@ -36,16 +37,24 @@ function localNow(tz, at = new Date()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function send(dir, st, uid, payload) {
|
async function send(dir, st, uid, payload) {
|
||||||
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); // (reminders also land in the Notifications list)
|
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list)
|
||||||
keys();
|
keys(); const { ref, ...wire } = payload;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
await Promise.all(st.subs.map(async s => {
|
await Promise.all(st.subs.map(async s => {
|
||||||
try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); }
|
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); }
|
||||||
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
|
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
|
||||||
}));
|
}));
|
||||||
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
|
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A child with no reward goal for this week gets one reminder (in the Notifications list, and on their devices if they have any), in the daytime by their own clock. */
|
||||||
|
async function goalReminder(u, dir, st, now) {
|
||||||
|
const lt = localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now); if (lt.hm < '08:00' || lt.hm >= '20:00') return;
|
||||||
|
const wk = chores.goalNeeded(u.id, lt.day); if (!wk) return;
|
||||||
|
chores.goalReminded(u.id, wk);
|
||||||
|
await send(dir, st, u.id, { title: 'Pick your reward for this week', body: 'What are you aiming for? Choose a reward on the Chores page.', url: '#/chores', tag: 'goal:' + wk });
|
||||||
|
}
|
||||||
|
|
||||||
let lastEvents = 0;
|
let lastEvents = 0;
|
||||||
let lastDigest = 0;
|
let lastDigest = 0;
|
||||||
/** One pass over everyone: runs every minute. */
|
/** One pass over everyone: runs every minute. */
|
||||||
@@ -56,6 +65,7 @@ async function tick(now = new Date(), force = false) {
|
|||||||
for (const u of users.all()) {
|
for (const u of users.all()) {
|
||||||
if (u.disabled) continue;
|
if (u.disabled) continue;
|
||||||
const dir = users.dirOf(u.id), st = load(dir);
|
const dir = users.dirOf(u.id), st = load(dir);
|
||||||
|
await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message));
|
||||||
if (!st.subs.length) continue;
|
if (!st.subs.length) continue;
|
||||||
await users.runAs(u, async () => {
|
await users.runAs(u, async () => {
|
||||||
const lt = localNow(st.tz, now); let dirty = false;
|
const lt = localNow(st.tz, now); let dirty = false;
|
||||||
@@ -87,7 +97,7 @@ async function tick(now = new Date(), force = false) {
|
|||||||
const fresh = now2.filter(m => !seen.includes(m.id));
|
const fresh = now2.filter(m => !seen.includes(m.id));
|
||||||
if (fresh.length) {
|
if (fresh.length) {
|
||||||
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
||||||
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
|
await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
||||||
|
|||||||
+30
-3
@@ -99,6 +99,19 @@ async function sync() {
|
|||||||
return { ...out, list: tl.id };
|
return { ...out, list: tl.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Meal plan: which recipe is on which day (shared by the whole family), kept in DATA_DIR/mealplan.json
|
||||||
|
const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], planFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mealplan.json');
|
||||||
|
const readPlan = () => { try { const d = JSON.parse(fs.readFileSync(planFile(), 'utf8')); return { days: d && d.days && typeof d.days === 'object' ? d.days : {} }; } catch { return { days: {} }; } };
|
||||||
|
const writePlan = d => atomicWrite(planFile(), JSON.stringify(d), { mode: 0o600 });
|
||||||
|
const planBetween = (from, to) => { const out = {}; for (const [k, v] of Object.entries(readPlan().days)) if (k >= from && k <= to) out[k] = v; return out; };
|
||||||
|
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
|
||||||
|
async function addRecipe(slug, times) {
|
||||||
|
const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1));
|
||||||
|
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
|
||||||
|
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
|
||||||
try {
|
try {
|
||||||
@@ -127,11 +140,25 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
|
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
|
||||||
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
|
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
|
||||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1));
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await addRecipe(b.slug, +b.scale || 1);
|
||||||
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
|
|
||||||
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
|
|
||||||
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
|
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
|
||||||
}
|
}
|
||||||
|
if (p === '/plan' && req.method === 'GET') { const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); return json(res, 200, planBetween(from, to)); }
|
||||||
|
if (p === '/plan/add' && req.method === 'POST') {
|
||||||
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.date || '') || !slugOk(b.slug)) throw fail(400, 'Pick a day and a recipe');
|
||||||
|
const d = readPlan(), id = crypto.randomBytes(5).toString('hex'), cut = new Date(Date.now() - 90 * 864e5).toISOString().slice(0, 10);
|
||||||
|
for (const k of Object.keys(d.days)) if (k < cut) delete d.days[k];
|
||||||
|
(d.days[b.date] = d.days[b.date] || []).push({ id, slug: b.slug, name: String(b.name || b.slug).slice(0, 160), rid: idOk(b.rid) ? b.rid : '', meal: MEALS.includes(b.meal) ? b.meal : 'dinner', by: (users.current() || {}).id || '' });
|
||||||
|
if (d.days[b.date].length > 12) throw fail(400, 'That day is full'); writePlan(d); return json(res, 200, { ok: true, id });
|
||||||
|
}
|
||||||
|
if (p === '/plan/remove' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), d = readPlan(); for (const k of Object.keys(d.days)) { d.days[k] = d.days[k].filter(e => e.id !== b.id); if (!d.days[k].length) delete d.days[k]; } writePlan(d); return json(res, 200, { ok: true }); }
|
||||||
|
if (p === '/plan/shop' && req.method === 'POST') { // every planned meal in the range goes on the shopping list (a recipe planned twice counts twice)
|
||||||
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.from || '') || !DAY.test(b.to || '')) throw fail(400, 'Bad dates');
|
||||||
|
const count = {}; for (const es of Object.values(planBetween(b.from, b.to))) for (const e of es) count[e.slug] = (count[e.slug] || 0) + 1;
|
||||||
|
const slugs = Object.keys(count); if (!slugs.length) throw fail(400, 'Nothing is planned in those days'); let added = 0;
|
||||||
|
for (const sl of slugs) { try { await addRecipe(sl, count[sl]); added++; } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
|
||||||
|
const s = await sync(); return json(res, 200, { ok: true, ...s, recipes: added });
|
||||||
|
}
|
||||||
if (p === '/shopping/add' && req.method === 'POST') {
|
if (p === '/shopping/add' && req.method === 'POST') {
|
||||||
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
|
||||||
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 0, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true });
|
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 0, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true });
|
||||||
|
|||||||
@@ -660,7 +660,9 @@ async function handleRequest(req, res) {
|
|||||||
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||||
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); }
|
||||||
|
if (pathname === '/api/export') return backup.handle(req, res, user.name);
|
||||||
|
if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||||
if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res);
|
if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res);
|
||||||
if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); }
|
if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); }
|
||||||
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Backup restore and personal export: a good backup is swapped in (the old data kept aside), unsafe archives are refused, and an export holds only your own data without sign-in tokens.
|
||||||
|
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), { execFileSync } = require('child_process');
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-backup-')); process.env.DATA_DIR = dir; process.env.NO_RESTART_AFTER_RESTORE = '1';
|
||||||
|
const users = require('../users'), backup = require('../backup');
|
||||||
|
const a = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b = users.create({ name: 'Bo', role: 'user', handle: 'ZP' });
|
||||||
|
fs.writeFileSync(path.join(users.dirOf(a.id), 'tasks.json'), '{"mine":1}'); fs.writeFileSync(path.join(users.dirOf(a.id), 'mail.json'), '{"token":"secret"}'); fs.writeFileSync(path.join(users.dirOf(b.id), 'tasks.json'), '{"bo":1}');
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(a, () => backup.handle(req, res, 'Ann'))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port;
|
||||||
|
const mk = (name, fn) => { const w = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bk-src-')); fn(w); const f = path.join(os.tmpdir(), name); execFileSync('tar', ['-czf', f, '-C', w, '.']); return fs.readFileSync(f); };
|
||||||
|
try {
|
||||||
|
// export: only my data, no tokens
|
||||||
|
const ex = Buffer.from(await (await fetch(base + '/api/export')).arrayBuffer()), ef = path.join(os.tmpdir(), 'nehub-ex.tar.gz'); fs.writeFileSync(ef, ex);
|
||||||
|
const list = execFileSync('tar', ['-tzf', ef]).toString(); assert.match(list, /me\/tasks\.json/); assert.ok(!/mail\.json/.test(list), 'sign-in tokens are left out'); assert.ok(!list.includes(b.id), 'nobody else\'s data');
|
||||||
|
// a good backup is swapped in; the old data is kept
|
||||||
|
const good = mk('good.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"restored":true}'); fs.mkdirSync(path.join(w, 'users')); });
|
||||||
|
let r = await fetch(base + '/api/backup/restore', { method: 'POST', body: good }); let j = await r.json(); assert.equal(r.status, 200, JSON.stringify(j)); assert.ok(j.restarting);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true); assert.ok(fs.existsSync(path.join(dir, j.kept, 'users')), 'the old accounts are kept aside');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dir, '.restore')) && !fs.existsSync(path.join(dir, '.restore.tar.gz')), 'the staging files are cleaned up');
|
||||||
|
// unsafe or foreign archives are refused and change nothing
|
||||||
|
const evil = mk('evil.tar.gz', w => { fs.writeFileSync(path.join(w, 'secret.json'), '{"evil":true}'); fs.symlinkSync('/etc/passwd', path.join(w, 'users')); });
|
||||||
|
r = await fetch(base + '/api/backup/restore', { method: 'POST', body: evil }); assert.equal(r.status, 400); assert.match((await r.json()).error, /links/); assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'secret.json'), 'utf8')).restored, true, 'nothing changed');
|
||||||
|
const foreign = mk('foreign.tar.gz', w => fs.writeFileSync(path.join(w, 'readme.txt'), 'hi')); r = await fetch(base + '/api/backup/restore', { method: 'POST', body: foreign }); assert.equal(r.status, 400); assert.match((await r.json()).error, /NE Hub backup/);
|
||||||
|
r = await fetch(base + '/api/backup/restore', { method: 'POST', body: Buffer.from('not a tar') }); assert.equal(r.status, 400);
|
||||||
|
console.log('backup test ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close();
|
||||||
|
});
|
||||||
+13
-2
@@ -6,15 +6,26 @@ const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }),
|
|||||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
||||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
||||||
try {
|
try {
|
||||||
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] });
|
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [], plan: null });
|
||||||
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
||||||
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
||||||
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
||||||
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
||||||
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
||||||
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
||||||
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own");
|
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [], plan: null }, "each person's place and marks are their own");
|
||||||
assert.equal((await fetch(base + '/nope')).status, 404);
|
assert.equal((await fetch(base + '/nope')).status, 404);
|
||||||
|
// devotion: a reading plan with check-ins, and the shared prayer list
|
||||||
|
r = await j(await post('/plan', { start: '2026-01-01' })); assert.equal(r.plan.start, '2026-01-01'); assert.equal((await post('/plan', { start: 'nope' })).status, 400);
|
||||||
|
r = await j(await post('/plan/check', { day: 3, on: true })); await post('/plan/check', { day: 1, on: true }); r = await j(await post('/plan/check', { day: 3, on: true })); assert.deepEqual(r.plan.done, [1, 3], 'check-ins are kept in order, once each');
|
||||||
|
assert.equal((await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } }))).plan, null, 'each person has their own plan');
|
||||||
|
r = await j(await post('/prayers', { text: 'For Grandma' })); assert.equal(r.length, 1); const pid = r[0].id; assert.equal((await post('/prayers', { text: ' ' })).status, 400);
|
||||||
|
assert.equal((await j(await fetch(base + '/prayers', { headers: { 'x-user': 'b' } }))).length, 1, 'the whole family sees the list');
|
||||||
|
r = await j(await post('/prayers/answer', { id: pid, on: true }, { 'x-user': 'b' })); assert.equal(r[0].answered, true);
|
||||||
|
assert.equal((await post('/prayers/delete', { id: pid }, { 'x-user': 'b' })).status, 403, 'only the author or an admin removes a request'); assert.equal((await j(await post('/prayers/delete', { id: pid }))).length, 0);
|
||||||
|
// every verse of the day exists in the text
|
||||||
|
{ const code = fs.readFileSync(path.join(__dirname, '..', 'public', 'devotion-view.js'), 'utf8'), list = JSON.parse(/const DV_VERSES = (\[.*?\]);/.exec(code)[1].replace(/'/g, '"')), dd = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8'));
|
||||||
|
for (const ref of list) { const m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(ref), bk = dd.books.find(b => b.id === m[1]); assert.ok(bk && bk.chapters[+m[2] - 1] && bk.chapters[+m[2] - 1].some(x => x[0] === 'v' && x[1] === +m[3]), ref + ' is in the Bible text'); } }
|
||||||
// the bundled text
|
// the bundled text
|
||||||
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
||||||
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
|||||||
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot & Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
|
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot & Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
|
||||||
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
|
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
|
||||||
|
|
||||||
|
// ---- share cards: a song, film or book from the hub, with a picture and a link back
|
||||||
|
const card = { kind: 'song', title: 'Blue Moon', sub: 'Sinatra', art: '/plex/photo/:/transcode?width=300&url=%2Flibrary%2Fa', link: '#/music/album/123' };
|
||||||
|
for (const bad of [{ ...card, kind: 'virus' }, { ...card, title: ' ' }, { ...card, link: 'https://evil.example/x' }, { ...card, link: 'javascript:alert(1)' }, { ...card, link: '#/music/<b>' }, { ...card, art: 'https://evil.example/a.jpg' }, { ...card, art: '//evil.example/a.jpg' }, { ...card, art: '/plex/../etc/passwd' }]) assert.equal((await post('/send', { conv: g2.id, share: bad }, 'a')).status, 400, 'a bad card is refused: ' + JSON.stringify(bad));
|
||||||
|
assert.equal((await post('/send', { conv: g2.id, share: card }, 'c')).status, 200, 'sharing needs no words'); await post('/read', { id: g2.id }, 'b');
|
||||||
|
const sh = (await j(await post('/send', { conv: g2.id, text: 'Listen to this', share: { ...card, title: 'T'.repeat(500), sub: 'S'.repeat(500) } }, 'a'))).msg; assert.equal(sh.share.title.length, 120, 'long words are cut'); assert.equal(sh.share.sub.length, 120); assert.equal(sh.text, 'Listen to this', 'with a note');
|
||||||
|
assert.equal((await j(await call('/convs', {}, 'b'))).unread, 1, 'a share counts as unread (the earlier one was read)'); assert.match((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, /^Ann: 🎵 TTT/, 'listed by its title');
|
||||||
|
const sh2 = (await j(await post('/send', { conv: g2.id, share: { kind: 'podcastep', title: 'Ep 4', link: '#/podcast/show/x' } }, 'a'))).msg; assert.equal(sh2.share.art, '', 'the picture is optional'); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share.link, '#/podcast/show/x');
|
||||||
|
assert.equal(chat.cleanShare({ kind: 'movie', title: 'Up', link: '#/video/item/9' }).title, 'Up');
|
||||||
|
assert.ok(require('../inbox').load(P.b.id).some(i => i.body === 'Ann: 🎙️ Shared a podcast episode: Ep 4' && i.url.startsWith('#/chat/')), 'the Notifications list says what was shared');
|
||||||
|
assert.equal((await j(await call('/search?q=sinatra', {}, 'b'))).results.length, 1, 'search finds a card by its subtitle'); assert.equal((await j(await call('/search?q=ep%204', {}, 'b'))).results.length, 1);
|
||||||
|
const fws = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: sh.id }, 'a'))).msg; assert.equal(fws.share.link, '#/music/album/123', 'a forwarded card stays a card');
|
||||||
|
assert.equal((await post('/delete', { conv: g2.id, msg: sh2.id }, 'a')).status, 200); assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sh2.id).share, undefined, 'a deleted card shows nothing');
|
||||||
|
|
||||||
|
|
||||||
// ---- family extras: announcement and quiet hours
|
// ---- family extras: announcement and quiet hours
|
||||||
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
|
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
|
||||||
|
|||||||
+24
-1
@@ -1,7 +1,8 @@
|
|||||||
// Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week.
|
// Chores: when each kind of chore is due, who it is given to, ticking off and undoing, who may tick what, and the points for the week.
|
||||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.TZ = 'UTC'; // (the reminder goes out in the daytime by the server's clock)
|
||||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-'));
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chores-'));
|
||||||
const users = require('../users'), chores = require('../chores');
|
const users = require('../users'), chores = require('../chores'), push = require('../push'), inbox = require('../inbox');
|
||||||
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
|
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chores'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chores'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
|
||||||
users.update(P.b.id, { kid: { on: true } }); users.update(P.c.id, { kid: { on: true } }); // (points and rewards are for child accounts)
|
users.update(P.b.id, { kid: { on: true } }); users.update(P.c.id, { kid: { on: true } }); // (points and rewards are for child accounts)
|
||||||
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
|
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chores.handle(req, res))).listen(0, async () => {
|
||||||
@@ -47,6 +48,28 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
|||||||
assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week');
|
assert.equal((await j(await call('/week?date=' + chores.addDays(today, -7), {}, 'a'))).people.every(x => x.points === 0), true, 'last week had no points: they start again each week');
|
||||||
const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true });
|
const rc = await j(await post('/reward', { title: 'Bake together', points: 3, for: [P.c.id] })); wk = await j(await call('/week?date=' + today, {}, 'a')); assert.ok(wk.people.find(x => x.name === 'Cy').earned.includes(rc.id), 'a reward just for Cy: Cy earns it'); assert.ok(!wk.people.find(x => x.name === 'Bob').tiers.includes(rc.id), 'and it is not on Bob\'s list'); assert.equal((await post('/reward', { title: 'x', points: 3, for: [] })).status, 400, 'someone has to be chosen'); await post('/reward', { id: rc.id, remove: true });
|
||||||
assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0);
|
assert.equal((await post('/give', { user: P.b.id, reward: r5.id, undo: true })).status, 200); assert.equal((await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === 'Bob').given.length, 0);
|
||||||
|
// a child chooses the reward they are aiming for
|
||||||
|
const rcy = await j(await post('/reward', { title: 'Cy only', points: 4, for: [P.c.id] }));
|
||||||
|
const goalOf = async who => (await j(await call('/week?date=' + today, {}, 'a'))).people.find(x => x.name === who).goal;
|
||||||
|
assert.equal((await post('/goal', { reward: r50.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r50.id, 'Bob is aiming for the trip');
|
||||||
|
assert.equal((await post('/goal', { reward: rcy.id }, 'b')).status, 400, 'not one that is only for Cy'); assert.equal((await post('/goal', { reward: 'nope' }, 'b')).status, 400);
|
||||||
|
assert.equal((await post('/goal', { reward: r5.id, user: P.c.id }, 'b')).status, 200); assert.equal(await goalOf('Bob'), r5.id, 'a child only ever sets their own'); assert.equal(await goalOf('Cy'), null);
|
||||||
|
assert.equal((await post('/goal', { reward: rcy.id, user: P.c.id }, 'a')).status, 200); assert.equal(await goalOf('Cy'), rcy.id, 'an admin can choose for a child');
|
||||||
|
assert.equal((await post('/goal', { reward: r5.id }, 'a')).status, 400, 'goals are for children');
|
||||||
|
const tmp = await j(await post('/reward', { title: 'Temp', points: 9 })); await post('/goal', { reward: tmp.id }, 'b'); assert.equal(await goalOf('Bob'), tmp.id); await post('/reward', { id: tmp.id, remove: true }); assert.equal(await goalOf('Bob'), null, 'a removed reward is no longer anyone\'s goal');
|
||||||
|
await post('/goal', { reward: r5.id }, 'b'); assert.equal((await post('/goal', { reward: '' }, 'b')).status, 200); assert.equal(await goalOf('Bob'), null, 'no reward clears it');
|
||||||
|
assert.equal((await j(await call('/', {}, 'b'))).me.kid, true, 'the page knows who is a child');
|
||||||
|
// a goal is for one week and starts empty each Monday; a child with none is reminded once
|
||||||
|
const wkGoal = async (who, date) => (await j(await call('/week?date=' + date, {}, 'a'))).people.find(x => x.name === who).goal, thisMon = (await j(await call('/week?date=' + today, {}, 'a'))).from;
|
||||||
|
await post('/goal', { reward: r5.id }, 'b'); assert.equal(await wkGoal('Bob', today), r5.id); assert.equal(await wkGoal('Bob', chores.addDays(today, 7)), null, 'next week starts empty'); assert.equal(await wkGoal('Bob', chores.addDays(today, -7)), null, 'and last week had none');
|
||||||
|
assert.equal(chores.goalNeeded(P.b.id, today), null, 'a goal is chosen: no reminder'); await post('/goal', { reward: '' }, 'b');
|
||||||
|
assert.equal(chores.goalNeeded(P.b.id, today), thisMon, 'no goal yet: a reminder is due'); assert.equal(chores.goalNeeded(P.a.id, today), null, 'not for adults');
|
||||||
|
const later = chores.addDays(thisMon, 14), at = new Date(Date.parse(later + 'T10:00:00Z')), mine = () => inbox.load(P.b.id).filter(i => i.tag === 'goal:' + later);
|
||||||
|
await push.tick(at, true); assert.equal(mine().length, 1, 'a child with no goal is reminded in the daytime'); assert.equal(inbox.load(P.a.id).some(i => /^goal:/.test(i.tag)), false, 'not an adult');
|
||||||
|
await push.tick(new Date(at.getTime() + 60e3), true); assert.equal(mine()[0].count, 1, 'only once a week');
|
||||||
|
await post('/goal', { reward: '', user: P.c.id }); await push.tick(new Date(Date.parse(thisMon + 'T10:00:00Z') + 2 * 864e5), true); const cyRem = () => inbox.load(P.c.id).filter(i => i.tag === 'goal:' + thisMon); assert.equal(cyRem().length, 1); assert.equal(cyRem()[0].read, false);
|
||||||
|
await post('/goal', { reward: rcy.id }, 'c'); assert.equal(cyRem()[0].read, true, 'choosing a goal settles the reminder');
|
||||||
|
const early = new Date(Date.parse(chores.addDays(later, 7) + 'T03:00:00Z')); await push.tick(early, true); assert.equal(inbox.load(P.b.id).some(i => i.tag === 'goal:' + chores.addDays(later, 7)), false, 'not in the middle of the night');
|
||||||
// delete removes its history
|
// delete removes its history
|
||||||
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
|
assert.equal((await call('/' + once.id, { method: 'DELETE' }, 'b')).status, 403); assert.equal((await call('/' + once.id, { method: 'DELETE' })).status, 200); assert.equal((await j(await call('/summary', {}, 'a'))).people.find(x => x.name === 'Bob').points, 3);
|
||||||
assert.equal((await call('/?date=nope')).status, 400);
|
assert.equal((await call('/?date=nope')).status, 400);
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
|
|||||||
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
|
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
|
||||||
// calendar birthdays
|
// calendar birthdays
|
||||||
const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday");
|
const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday");
|
||||||
|
// anniversaries and gift ideas
|
||||||
|
{ const r = await post('/', { name: 'Ann Iversary', anniversary: '2010-08-12', gifts: 'Scarf\nBook' }); const a = await r.json(); assert.equal(a.anniversary, '2010-08-12'); assert.equal(a.gifts, 'Scarf\nBook');
|
||||||
|
const ev = contacts.birthdayEvents({ fd: '2026-08-01', td: '2026-08-31' }, people.a.id).filter(e => e.contactId === a.id); assert.equal(ev.length, 1); assert.equal(ev[0].title, "Ann Iversary's anniversary (16)"); assert.equal(ev[0].start, '2026-08-12'); }
|
||||||
const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known');
|
const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known');
|
||||||
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
|
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
|
||||||
// each person has their own book
|
// each person has their own book
|
||||||
|
|||||||
+12
-1
@@ -14,7 +14,18 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
|||||||
await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3);
|
await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3);
|
||||||
r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0);
|
r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0);
|
||||||
await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts');
|
await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts');
|
||||||
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); console.log('inbox ok');
|
await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0);
|
||||||
|
// reading or clearing a notification settles the chat itself, and opening the chat settles the notification
|
||||||
|
const unr = async () => (await j(await call('/api/chat/status', {}, 'b'))).unread;
|
||||||
|
assert.equal(await unr(), 0, 'clearing the notifications read the chat');
|
||||||
|
await post('/api/chat/send', { conv: dm.id, text: 'one more' }, 'a'); assert.equal(await unr(), 1); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 1);
|
||||||
|
await post('/api/chat/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 0, 'opening the chat settled its notification');
|
||||||
|
await post('/api/chat/send', { conv: dm.id, text: 'last' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(await unr(), 1);
|
||||||
|
await post('/api/inbox/read', { id: r.items[0].id }, 'b'); assert.equal(await unr(), 0, 'marking one notification read read its chat');
|
||||||
|
await post('/api/chat/send', { conv: dm.id, text: 'and again' }, 'a'); assert.equal(await unr(), 1);
|
||||||
|
await post('/api/inbox/read', { all: true }, 'b'); assert.equal(await unr(), 0, 'mark all read read the chats too');
|
||||||
|
await post('/api/chat/announcement', { text: 'Again' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.ok(r.items[0].ref && r.items[0].ref.announce, 'an announcement knows which one it was');
|
||||||
|
console.log('inbox ok');
|
||||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Listening stats: time listened is counted from progress reports (seeks are not), per day and kind; finished items and the day streak are worked out.
|
||||||
|
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-listen-'));
|
||||||
|
const users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), progress = require('../progress');
|
||||||
|
const pod = progress('podcast-progress.json', 'podcast'), ab = progress('audiobook-progress.json', 'audiobook');
|
||||||
|
users.runAs(me, () => {
|
||||||
|
const t0 = Date.now() - 60e3;
|
||||||
|
pod.merge({ e1: { pos: 100, dur: 3000, t: t0 } }); pod.merge({ e1: { pos: 130, dur: 3000, t: t0 + 30e3 } }); // 30 s listened in 30 s
|
||||||
|
pod.merge({ e1: { pos: 1500, dur: 3000, t: t0 + 40e3 } }); // a seek: not counted
|
||||||
|
ab.merge({ b1: { pos: 7000, dur: 7100, t: t0 } }); ab.merge({ b1: { pos: 7090, dur: 7100, t: t0 + 50e3 } }); // 50 s counted at most, and the book is finished
|
||||||
|
let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1);
|
||||||
|
pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day');
|
||||||
|
const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing');
|
||||||
|
console.log('listening test ok');
|
||||||
|
});
|
||||||
@@ -55,6 +55,13 @@ const fake = http.createServer((req, res) => {
|
|||||||
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
|
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
|
||||||
// an older Mealie that only knows the list form of "add recipe"
|
// an older Mealie that only knows the list form of "add recipe"
|
||||||
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
|
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
|
||||||
|
// meal plan: shared, by day; the week's ingredients go on the shopping list
|
||||||
|
assert.equal((await post('/plan/add', { date: 'soon', slug: 'bread' })).status, 400, 'a real day is needed');
|
||||||
|
await post('/plan/add', { date: '2027-03-02', slug: 'bread', name: 'Bread', meal: 'dinner' }); await post('/plan/add', { date: '2027-03-04', slug: 'bread', name: 'Bread', meal: 'lunch' }); await post('/plan/add', { date: '2027-03-20', slug: 'bread', name: 'Bread' });
|
||||||
|
j = await (await get('/plan?from=2027-03-02&to=2027-03-08')).json(); assert.deepEqual(Object.keys(j), ['2027-03-02', '2027-03-04'], 'only the days asked for'); assert.equal(j['2027-03-04'][0].meal, 'lunch');
|
||||||
|
j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
|
||||||
|
assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
|
||||||
|
await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
|
||||||
// a wrong token is explained
|
// a wrong token is explained
|
||||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||||
|
|||||||
@@ -110,9 +110,14 @@ async function fetchAudio(src, cookie, tmp) {
|
|||||||
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
||||||
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
||||||
}
|
}
|
||||||
|
// TRANSCRIBE_HOURS=22-6 keeps the (slow, CPU-hungry) speech-to-text to the night: a job waits in the queue until that window opens (local time, so set TZ). A job that has started finishes even if the window closes. Empty = any time.
|
||||||
|
const NIGHT = (process.env.TRANSCRIBE_HOURS || '').match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
|
||||||
|
const inWindow = () => { if (!NIGHT) return true; const a = +NIGHT[1], b = +NIGHT[2], h = new Date().getHours(); return a === b || (a < b ? h >= a && h < b : h >= a || h < b); };
|
||||||
|
async function untilWindow() { while (!inWindow()) await new Promise(r => setTimeout(r, 60e3)); }
|
||||||
function startJob(key, src, cookie, getAudio, ep) {
|
function startJob(key, src, cookie, getAudio, ep) {
|
||||||
jobs.set(key, { state: 'running', since: Date.now() });
|
jobs.set(key, { state: 'running', since: Date.now() });
|
||||||
chain = chain.then(async () => {
|
chain = chain.then(async () => {
|
||||||
|
await untilWindow();
|
||||||
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
||||||
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); }
|
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); }
|
||||||
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
|
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
|
||||||
|
|||||||
Reference in new issue
Block a user