From 647f82afa2dc9b511a4322be5bb0f2d8f2d15316 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:43:15 -0400 Subject: [PATCH] Notes: tags, Markdown, search highlight and sorting, trash and history, reminders, photos and files, reorder/sub-items/due dates, own templates, view-only sharing, offline edits, redesigned screens Co-Authored-By: Claude Sonnet 5.5 --- notes.js | 107 ++++++++++++++++---- public/index.html | 1 + public/notes-md.js | 31 ++++++ public/notes-view.js | 214 +++++++++++++++++++++++++++++++--------- public/offline-queue.js | 2 +- public/style.css | 32 +++++- push.js | 1 + 7 files changed, 315 insertions(+), 73 deletions(-) create mode 100644 public/notes-md.js diff --git a/notes.js b/notes.js index 4393147..da86cee 100644 --- a/notes.js +++ b/notes.js @@ -1,56 +1,122 @@ -// Notes and lists: notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone a note is shared with can edit it; -// checklist items are changed one at a time (tick, add, edit, remove), so two people ticking things at once never overwrite each other. Kept in DATA_DIR/notes.json. +// Notes and lists: notes and checklists that are private, shared with the whole family, or shared with chosen people (who can be given view-only access). +// Checklist items are changed one at a time (tick, add, edit, move, remove), so two people ticking things at once never overwrite each other. +// Notes also have tags, a reminder, photos and files, a short edit history and a trash (30 days). Kept in DATA_DIR/notes.json, files in DATA_DIR/note-files. const fs = require('fs'), path = require('path'), crypto = require('crypto'); const atomicWrite = require('./atomic'); const users = require('./users'); const { readBody } = require('./body'); -const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'notes.json'); +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'notes.json'), FILES = () => path.join(DATA_DIR, 'note-files'); const COLORS = ['', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink']; -const MAX_NOTES = 3000, MAX_ITEMS = 300, MAX_BODY = 50000; +const MAX_NOTES = 3000, MAX_ITEMS = 300, MAX_BODY = 50000, MAX_FILE = 8e6, MAX_FILES = 12, MAX_TAGS = 10, MAX_HIST = 20, TRASH_DAYS = 30; +const FILE_TYPES = { 'image/png': 1, 'image/jpeg': 1, 'image/gif': 1, 'image/webp': 1, 'application/pdf': 1, 'text/plain': 1 }; // shown in the browser; anything else is only ever downloaded const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra }); const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n); const newId = () => crypto.randomBytes(6).toString('hex'); +const DAY = /^\d{4}-\d{2}-\d{2}$/; let cache = null; const all = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.notes) ? j.notes : []; } catch { return []; } })()); const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ notes: all() }), { mode: 0o600 }); }; +const rmFiles = n => { try { fs.rmSync(path.join(FILES(), n.id), { recursive: true, force: true }); } catch { /* already gone */ } }; +/** Notes in the trash for more than a month are removed for good. */ +function purge() { const cut = Date.now() - TRASH_DAYS * 864e5, gone = all().filter(n => n.deleted && n.deleted < cut); if (!gone.length) return; gone.forEach(rmFiles); cache = all().filter(n => !gone.includes(n)); save(); } const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'notes'); }; const sees = (n, me) => n.owner === me.id || (n.share === 'family' && users.can(me, 'notes')) || (Array.isArray(n.share) && n.share.includes(me.id)); +const canEdit = (n, me) => n.owner === me.id || me.role === 'admin' || (n.share === 'family' ? !n.familyRo : !(n.ro || []).includes(me.id)); const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; }; -const view = (n, me) => ({ id: n.id, type: n.type, title: n.title, body: n.body, items: n.items, owner: n.owner, ownerName: nameOf(n.owner), mine: n.owner === me.id, share: n.share, sharedWith: Array.isArray(n.share) ? n.share.map(id => ({ id, name: nameOf(id) })) : [], color: n.color, pinned: n.pinned, archived: n.archived, created: n.created, updated: n.updated, updatedByName: n.updatedBy ? nameOf(n.updatedBy) : '', rev: n.rev }); +const view = (n, me) => ({ id: n.id, type: n.type, title: n.title, body: n.body, items: n.items, owner: n.owner, ownerName: nameOf(n.owner), mine: n.owner === me.id, share: n.share, sharedWith: Array.isArray(n.share) ? n.share.map(id => ({ id, name: nameOf(id), ro: (n.ro || []).includes(id) })) : [], + familyRo: !!n.familyRo, canEdit: canEdit(n, me), color: n.color, pinned: n.pinned, archived: n.archived, deleted: n.deleted || 0, tags: n.tags || [], remind: n.reminded ? 0 : n.remind || 0, files: n.files || [], history: (n.history || []).length, + created: n.created, updated: n.updated, updatedByName: n.updatedBy ? nameOf(n.updatedBy) : '', rev: n.rev }); const cleanShare = (v, me) => v === 'family' ? 'family' : Array.isArray(v) ? [...new Set(v.map(String))].filter(id => id !== me.id && live(id)).slice(0, 30) : 'me'; +const cleanTags = v => [...new Set((Array.isArray(v) ? v : []).map(t => str(t, 24).trim().toLowerCase().replace(/^#+/, '').replace(/\s+/g, '-')).filter(Boolean))].slice(0, MAX_TAGS); const touch = (n, me) => { n.updated = Date.now(); n.updatedBy = me.id; n.rev = (n.rev || 0) + 1; }; -const find = (id, me, ownerOnly) => { const n = all().find(x => x.id === id); if (!n || !sees(n, me)) throw fail(404, 'Note not found'); if (ownerOnly && n.owner !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who made it, or an admin, can do that'); return n; }; +const find = (id, me, ownerOnly, edit) => { + const n = all().find(x => x.id === id); if (!n || !sees(n, me)) throw fail(404, 'Note not found'); + if (ownerOnly && n.owner !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who made it, or an admin, can do that'); + if (edit && !canEdit(n, me)) throw fail(403, 'You can read this but not change it'); + return n; +}; +/** Keep the previous title and text so a bad edit can be undone. One snapshot per person per couple of minutes, so typing does not fill the history. */ +function snapshot(n, me, force) { + const last = (n.history || [])[0]; if (n.type !== 'note' || (!n.body && !n.title)) return; if (!force && last && last.by === me.id && Date.now() - last.t < 120e3) return; + if (last && last.title === n.title && last.body === n.body) return; + n.history = [{ t: Date.now(), by: me.id, title: n.title, body: n.body }, ...(n.history || [])].slice(0, MAX_HIST); +} +const tplFile = () => path.join(users.dir(), 'note-templates.json'); +const tplLoad = () => { try { const j = JSON.parse(fs.readFileSync(tplFile(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }; +const readRaw = (req, max) => new Promise((ok, no) => { const c = []; let n = 0, done = false; req.on('data', d => { if (done) return; n += d.length; if (n > max) { done = true; const e = new Error('That file is too large (8 MB at most)'); e.status = 413; return no(e); } c.push(d); }); req.on('end', () => { if (!done) ok(Buffer.concat(c)); }); req.on('error', e => { if (!done) no(e); }); }); async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/notes'.length) || '/', me = users.current(); try { - let m; - if (p === '/' && req.method === 'GET') return json(res, 200, { notes: all().filter(n => sees(n, me)).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).map(n => view(n, me)), people: users.all().filter(x => x.id !== me.id && live(x.id)).map(x => ({ id: x.id, name: x.name })) }); + let m; purge(); + if (p === '/' && req.method === 'GET') return json(res, 200, { notes: all().filter(n => sees(n, me) && (!n.deleted || n.owner === me.id || me.role === 'admin')).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).map(n => view(n, me)), people: users.all().filter(x => x.id !== me.id && live(x.id)).map(x => ({ id: x.id, name: x.name })) }); + if (p === '/templates' && req.method === 'GET') return json(res, 200, { templates: tplLoad() }); + if (p === '/templates' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 5e4) || '{}'), l = tplLoad(), name = str(b.name, 60).trim(), items = (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS); + if (!name || !items.length) throw fail(400, 'A template needs a name and some items'); if (l.length >= 30) throw fail(400, 'That is plenty of templates'); + const t = { id: newId(), name, items }; l.push(t); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, t); + } + if (p === '/templates/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); atomicWrite(tplFile(), JSON.stringify(tplLoad().filter(t => t.id !== id)), { mode: 0o600 }); return json(res, 200, { ok: true }); } + if (p === '/empty-trash' && req.method === 'POST') { const mine = all().filter(n => n.deleted && (n.owner === me.id || me.role === 'admin')); mine.forEach(rmFiles); cache = all().filter(n => !mine.includes(n)); save(); return json(res, 200, { removed: mine.length }); } if (p === '/' && req.method === 'POST') { // create, or change the note's own fields const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}'); if (!b.id) { if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes'); - const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 }; + const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), tags: cleanTags(b.tags), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 }; all().push(n); save(); return json(res, 200, view(n, me)); } - const n = find(String(b.id), me, b.share !== undefined); + const ownerOnly = b.share !== undefined || b.ro !== undefined || b.familyRo !== undefined || b.deleted !== undefined, n = find(String(b.id), me, ownerOnly, b.deleted === undefined); if (b.rev !== undefined && +b.rev !== n.rev && (b.title !== undefined || b.body !== undefined)) throw fail(409, 'Someone else changed this note while you were editing', { note: view(n, me) }); + if (b.restore !== undefined) { const h = (n.history || [])[+b.restore]; if (!h) throw fail(404, 'That version is gone'); snapshot(n, me, true); n.title = h.title; n.body = h.body; } + if ((b.title !== undefined && str(b.title, 120).trim() !== n.title) || (b.body !== undefined && n.type === 'note' && str(b.body, MAX_BODY) !== n.body)) snapshot(n, me); if (b.title !== undefined) n.title = str(b.title, 120).trim(); if (b.body !== undefined && n.type === 'note') n.body = str(b.body, MAX_BODY); - if (b.share !== undefined) n.share = cleanShare(b.share, me); if (b.color !== undefined && COLORS.includes(b.color)) n.color = b.color; + if (b.share !== undefined) n.share = cleanShare(b.share, me); + if (b.ro !== undefined) n.ro = (Array.isArray(b.ro) ? b.ro : []).map(String).filter(id => Array.isArray(n.share) && n.share.includes(id)); if (b.familyRo !== undefined) n.familyRo = !!b.familyRo; + if (b.color !== undefined && COLORS.includes(b.color)) n.color = b.color; if (b.pinned !== undefined) n.pinned = !!b.pinned; if (b.archived !== undefined) n.archived = !!b.archived; + if (b.tags !== undefined) n.tags = cleanTags(b.tags); + if (b.remind !== undefined) { const t = +b.remind; if (t && (!Number.isFinite(t) || t < Date.now() - 60e3 || t > Date.now() + 400 * 864e5)) throw fail(400, 'Pick a time in the future'); n.remind = t || 0; n.remindFor = me.id; n.reminded = false; } + if (b.deleted === false) n.deleted = 0; touch(n, me); save(); return json(res, 200, view(n, me)); } - if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { const n = find(m[1], me, true); cache = all().filter(x => x !== n); save(); return json(res, 200, { ok: true }); } + if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { // into the trash first; from the trash it is gone for good + const n = find(m[1], me, true); + if (n.deleted || u.searchParams.get('forever') === '1') { rmFiles(n); cache = all().filter(x => x !== n); } else { n.deleted = Date.now(); n.pinned = false; touch(n, me); } + save(); return json(res, 200, { ok: true }); + } + if ((m = /^\/([0-9a-f]{12})\/history$/.exec(p)) && req.method === 'GET') { const n = find(m[1], me); return json(res, 200, { versions: (n.history || []).map((h, i) => ({ i, t: h.t, by: nameOf(h.by), title: h.title, snippet: h.body.slice(0, 160) })) }); } + if ((m = /^\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // one photo or file, sent as the raw request body + const n = find(m[1], me, false, true); if ((n.files || []).length >= MAX_FILES) throw fail(400, 'That is plenty of files on one note'); + const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(), name = str(u.searchParams.get('name'), 120).replace(/[\\/]/g, '_').trim() || 'file'; + const data = await readRaw(req, MAX_FILE); if (!data.length) throw fail(400, 'That file is empty'); + const f = { id: newId(), name, type: FILE_TYPES[type] ? type : 'application/octet-stream', size: data.length, by: me.id, at: Date.now() }; + fs.mkdirSync(path.join(FILES(), n.id), { recursive: true, mode: 0o700 }); atomicWrite(path.join(FILES(), n.id, f.id), data, { mode: 0o600 }); + n.files = [...(n.files || []), f]; touch(n, me); save(); return json(res, 200, view(n, me)); + } + if ((m = /^\/([0-9a-f]{12})\/files\/([0-9a-f]{12})$/.exec(p)) && req.method === 'GET') { + const n = find(m[1], me), f = (n.files || []).find(x => x.id === m[2]); if (!f) throw fail(404, 'File not found'); + let data; try { data = fs.readFileSync(path.join(FILES(), n.id, f.id)); } catch { throw fail(404, 'File not found'); } + res.writeHead(200, { 'content-type': f.type, 'content-length': data.length, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'", 'content-disposition': (/^image\//.test(f.type) || f.type === 'application/pdf' ? 'inline' : 'attachment') + `; filename="${f.name.replace(/[^\w.\- ]/g, '_')}"` }); + return res.end(data); + } + if ((m = /^\/([0-9a-f]{12})\/files\/delete$/.exec(p)) && req.method === 'POST') { + const n = find(m[1], me, false, true), { id } = JSON.parse(await readBody(req, 1e4) || '{}'); n.files = (n.files || []).filter(f => f.id !== id); + try { fs.rmSync(path.join(FILES(), n.id, String(id).replace(/[^0-9a-f]/g, '')), { force: true }); } catch { /* already gone */ } touch(n, me); save(); return json(res, 200, view(n, me)); + } if ((m = /^\/([0-9a-f]{12})\/items$/.exec(p)) && req.method === 'POST') { // one change to a checklist - const n = find(m[1], me); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id)); - if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); n.items.push({ id: newId(), text: t, done: false, addedBy: me.name }); } + const n = find(m[1], me, false, true); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id)); + if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); const x = { id: newId(), text: t, done: false, addedBy: me.name }; if (DAY.test(b.due || '')) x.due = b.due; n.items.push(x); } else if (b.toggle) { const x = it(b.toggle); if (!x) throw fail(404, 'Item not found'); x.done = !!b.done; if (x.done) { x.by = me.id; x.byName = me.name; x.at = Date.now(); } else { delete x.by; delete x.byName; delete x.at; } } else if (b.edit) { const x = it(b.edit), t = str(b.text, 300).trim(); if (!x || !t) throw fail(400, 'Write the item'); x.text = t; } + else if (b.setDue) { const x = it(b.setDue); if (!x) throw fail(404, 'Item not found'); if (DAY.test(b.due || '')) x.due = b.due; else delete x.due; } + else if (b.indent) { const x = it(b.indent); if (!x) throw fail(404, 'Item not found'); const i = n.items.indexOf(x); if (b.level && i > 0) x.indent = 1; else delete x.indent; } + else if (b.move) { const x = it(b.move); if (!x) throw fail(404, 'Item not found'); n.items = n.items.filter(y => y !== x); const at = b.before ? n.items.findIndex(y => y.id === String(b.before)) : -1; n.items.splice(at < 0 ? n.items.length : at, 0, x); if (n.items[0] === x) delete x.indent; } else if (b.remove) n.items = n.items.filter(x => x.id !== String(b.remove)); else if (b.clearDone) n.items = n.items.filter(x => !x.done); + else if (b.uncheckAll) n.items.forEach(x => { x.done = false; delete x.by; delete x.byName; delete x.at; }); else throw fail(400, 'Nothing to do'); touch(n, me); save(); return json(res, 200, view(n, me)); } @@ -58,13 +124,18 @@ async function handle(req, res) { } 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, ...(e.extra || {}) }); } } -/** Notes and lists this person can see that mention the words: [{ id, type, title, snippet, share }]. */ +/** Notes and lists this person can see that mention the words (or a tag): [{ id, type, title, snippet, share }]. */ function search(me, q) { const needle = q.toLowerCase(), out = []; - for (const n of all()) { if (!sees(n, me) || n.archived) continue; const text = n.type === 'list' ? n.items.map(i => i.text).join(' · ') : n.body, hay = (n.title + ' ' + text).toLowerCase(); if (!hay.includes(needle)) continue; + for (const n of all()) { if (!sees(n, me) || n.archived || n.deleted) continue; const text = n.type === 'list' ? n.items.map(i => i.text).join(' · ') : n.body, hay = (n.title + ' ' + text + ' ' + (n.tags || []).map(t => '#' + t).join(' ')).toLowerCase(); if (!hay.includes(needle)) continue; const at = text.toLowerCase().indexOf(needle); out.push({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), snippet: (at > 20 ? '…' : '') + text.slice(Math.max(0, at - 20), Math.max(0, at - 20) + 90), share: n.share === 'family' ? 'Family' : n.share === 'me' ? 'Just me' : 'Shared' }); } return out.slice(0, 8); } /** Notes and lists shared with the whole family, for the family board. */ -const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 20).map(n => ({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), pinned: n.pinned, total: n.items.length, done: n.items.filter(i => i.done).length, next: (n.items.find(i => !i.done) || {}).text || '', items: n.type === 'list' ? n.items.slice(0, 80).map(i => ({ text: i.text, done: !!i.done, by: i.addedBy || '' })) : [], body: n.type === 'note' ? n.body.slice(0, 400) : '' })); -module.exports = { handle, search, familyNotes }; +const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived && !n.deleted).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 20).map(n => ({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), pinned: n.pinned, total: n.items.length, done: n.items.filter(i => i.done).length, next: (n.items.find(i => !i.done) || {}).text || '', items: n.type === 'list' ? n.items.slice(0, 80).map(i => ({ text: i.text, done: !!i.done, by: i.addedBy || '' })) : [], body: n.type === 'note' ? n.body.slice(0, 400) : '' })); +/** Reminders whose time has come (each fires once): [{ uid, id, title }]. Called every minute by the push timer. */ +function dueReminders(now = Date.now()) { + const out = []; for (const n of all()) if (n.remind && !n.reminded && !n.deleted && n.remind <= now) { n.reminded = true; out.push({ uid: n.remindFor || n.owner, id: n.id, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note') }); } + if (out.length) save(); return out; +} +module.exports = { handle, search, familyNotes, dueReminders }; diff --git a/public/index.html b/public/index.html index e28190d..f623dde 100644 --- a/public/index.html +++ b/public/index.html @@ -99,6 +99,7 @@ + diff --git a/public/notes-md.js b/public/notes-md.js new file mode 100644 index 0000000..f3e65fd --- /dev/null +++ b/public/notes-md.js @@ -0,0 +1,31 @@ +'use strict'; +// ---- A small, safe Markdown renderer for notes: headings, bold, italic, strike, code, links, quotes, bullet and numbered lists, and "- [ ]" tick boxes. +// Everything is escaped first, and only http(s) links are made clickable, so a note can never inject a script or a strange link. +function ntMd(src) { + const inline = t => esc(t) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1').replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, '$1$2').replace(/~~([^~]+)~~/g, '$1') + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1$2'); + const lines = String(src || '').replace(/\r/g, '').split('\n'), out = []; let list = null, code = false, para = []; + const endPara = () => { if (para.length) { out.push('

' + para.map(inline).join('
') + '

'); para = []; } }; + const endList = () => { if (list) { out.push(``); list = null; } }; + lines.forEach((raw, i) => { + if (/^```/.test(raw)) { endPara(); endList(); out.push(code ? '' : '
'); code = !code; return; }
+    if (code) { out.push(esc(raw) + '\n'); return; }
+    let m;
+    if ((m = /^(\s*)[-*+] \[( |x|X)\] (.*)$/.exec(raw))) { endPara(); if (list !== 'ul') { endList(); out.push('
'); + return out.join(''); +} +/** The text of a note without the Markdown marks, for the small preview on a card. */ +const ntPlain = s => String(s || '').replace(/```/g, '').replace(/^#{1,3} /gm, '').replace(/^\s*[-*+] \[( |x|X)\] /gm, (m, x) => x === ' ' ? '☐ ' : '☑ ').replace(/^\s*[-*+] /gm, '• ').replace(/^> ?/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/~~([^~]+)~~/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`([^`]+)`/g, '$1'); diff --git a/public/notes-view.js b/public/notes-view.js index dd904ca..9d9f670 100644 --- a/public/notes-view.js +++ b/public/notes-view.js @@ -1,13 +1,20 @@ 'use strict'; // ---- Notes: notes and checklists that are private, shared with the family, or shared with chosen people (see notes.js). Checklist items change one at a time, so two -// people ticking the same list never overwrite each other, and the open list refreshes itself every few seconds. -const nt = { notes: [], people: [], filter: 'all', q: '', timer: null }; +// people ticking the same list never overwrite each other, and the open list refreshes itself every few seconds. Notes are Markdown, with tags, a reminder, photos and +// files, an edit history, a trash, and checklists you can reorder, indent, give due dates, reset and keep as your own templates. +const nt = { notes: [], people: [], filter: 'all', tag: '', sort: 'updated', q: '', timer: null, tpl: [] }; const ntApi = (p, o) => jfetch('/api/notes' + p, o); const ntPost = (p, b) => ntApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }); const NT_COLORS = { '': 'var(--panel)', red: '#e5484d', orange: '#f08a24', yellow: '#e5b800', green: '#30a46c', teal: '#12a594', blue: '#3e63dd', purple: '#8e4ec6', pink: '#d6409f' }; const NT_TEMPLATES = [['Grocery list', ['Milk', 'Eggs', 'Bread', 'Fruit', 'Vegetables', 'Coffee']], ['Packing list', ['Clothes', 'Toiletries', 'Chargers', 'Documents', 'Snacks']], ['Weekend to-do', ['Laundry', 'Groceries', 'Clean the kitchen', 'Mow the lawn']]]; const ntWho = n => n.share === 'family' ? 'Family' : n.share === 'me' || !n.share ? 'Just me' : 'Shared with ' + (n.sharedWith.length ? n.sharedWith.map(x => x.name).slice(0, 2).join(', ') + (n.sharedWith.length > 2 ? ' +' + (n.sharedWith.length - 2) : '') : 'no one'); const ntDone = n => n.items.filter(i => i.done).length; +const ntYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +/** "Due Tue 3" with a class for overdue / today. */ +const ntDue = due => { if (!due) return null; const t = ntYmd(new Date()), d = new Date(due + 'T12:00:00'); return { text: due === t ? 'Today' : due === ntYmd(new Date(Date.now() + 864e5)) ? 'Tomorrow' : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }), cls: due < t ? 'over' : due === t ? 'today' : '' }; }; +const ntRemind = ms => new Date(ms).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +const ntSnippet = (text, q) => { const t = ntPlain(text).replace(/\s+/g, ' ').trim(), at = q ? t.toLowerCase().indexOf(q) : -1; return at < 0 ? t.slice(0, 160) : (at > 30 ? '…' : '') + t.slice(Math.max(0, at - 30), at + 130); }; +const ntMark = (html, q) => q && q.length > 1 && !/^(amp|lt|gt|quot|#?\d+)$/i.test(q) ? html.replace(new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'), '$1') : html; function viewNotes(args = []) { clearInterval(nt.timer); nt.timer = null; @@ -20,91 +27,200 @@ async function ntHome() { const v = $('#view'); v.innerHTML = '

Notes

Loading…

'; try { await ntLoad(); } catch (e) { v.innerHTML = `

Notes

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/notes')) return; - const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']]; - v.innerHTML = `

Notes

-
${filters.map(([k, l]) => `${l}`).join('')}
`; + const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived'], ['trash', 'Trash']]; + v.innerHTML = `

Notes

+
+
${filters.map(([k, l]) => `${l}`).join('')}
`; + $('#nts').value = nt.sort; const draw = () => { - const q = nt.q.toLowerCase(), f = nt.filter; - const rows = nt.notes.filter(n => (f === 'archived' ? n.archived : !n.archived) && (f === 'list' ? n.type === 'list' : f === 'note' ? n.type === 'note' : f === 'mine' ? n.mine : f === 'shared' ? n.share !== 'me' : true) - && (!q || (n.title + ' ' + n.body + ' ' + n.items.map(i => i.text).join(' ')).toLowerCase().includes(q))); - $('#ntgrid').innerHTML = rows.map(n => `
${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? icon('bookmark') : ''}
- ${n.type === 'list' ? `
${n.items.slice(0, 5).map(i => `${i.done ? '☑' : '☐'} ${esc(i.text)}`).join('')}${n.items.length > 5 ? `+ ${n.items.length - 5} more` : ''}
${ntDone(n)} of ${n.items.length} done` : `

${esc(n.body.slice(0, 180))}

`} - ${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}
`).join('') || `

${nt.notes.length ? 'Nothing matches.' : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}

`; + const q = nt.q.toLowerCase().replace(/^#/, ''), f = nt.filter, trash = f === 'trash'; + const base = nt.notes.filter(n => trash ? n.deleted : !n.deleted); + const tags = [...base.reduce((m, n) => { n.tags.forEach(t => m.set(t, (m.get(t) || 0) + 1)); return m; }, new Map())].sort((a, b) => b[1] - a[1]); + $('#ntags').innerHTML = tags.length ? tags.map(([t]) => `#${esc(t)}`).join('') : ''; + $('#ntags').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); nt.tag = nt.tag === a.dataset.t ? '' : a.dataset.t; draw(); }); + let rows = base.filter(n => (trash || (f === 'archived' ? n.archived : !n.archived)) && (f === 'list' ? n.type === 'list' : f === 'note' ? n.type === 'note' : f === 'mine' ? n.mine : f === 'shared' ? n.share !== 'me' : true) + && (!nt.tag || n.tags.includes(nt.tag)) && (!q || (n.title + ' ' + n.body + ' ' + n.items.map(i => i.text).join(' ') + ' ' + n.tags.join(' ')).toLowerCase().includes(q))); + const by = { updated: (a, b) => b.updated - a.updated, created: (a, b) => b.created - a.created, title: (a, b) => (a.title || '~').localeCompare(b.title || '~', undefined, { sensitivity: 'base' }) }[nt.sort]; + rows = rows.sort((a, b) => (trash ? 0 : (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) || by(a, b)); + const card = n => { + const due = n.type === 'list' ? n.items.filter(i => !i.done && i.due).map(i => ntDue(i.due)).sort((a, b) => (a.cls === 'over' ? 0 : 1) - (b.cls === 'over' ? 0 : 1))[0] : null, pct = n.items.length ? Math.round(ntDone(n) / n.items.length * 100) : 0; + return `
${ntMark(esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note')), q)}${n.pinned ? icon('bookmark') : ''}
+ ${n.type === 'list' ? `
${n.items.slice(0, 5).map(i => `${i.done ? '☑' : '☐'} ${ntMark(esc(i.text), q)}`).join('')}${n.items.length > 5 ? `+ ${n.items.length - 5} more` : ''}
${n.items.length ? `
` : ''}` + : `

${ntMark(esc(ntSnippet(n.body, q)), q)}

`} + ${n.tags.length || due || n.remind || n.files.length || n.canEdit === false ? `
${n.tags.slice(0, 3).map(t => `#${esc(t)}`).join('')}${due ? `${icon('cal')}${esc(due.text)}` : ''}${n.remind ? `${icon('bell')}` : ''}${n.files.length ? `${icon('doc')}${n.files.length}` : ''}${n.canEdit === false ? 'View only' : ''}
` : ''} + ${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}${trash ? `
` : ''}
`; + }; + const pinned = !trash && !q && f !== 'archived' ? rows.filter(n => n.pinned) : [], rest = pinned.length ? rows.filter(n => !n.pinned) : rows; + $('#ntgrid').innerHTML = (trash && rows.length ? `
Notes here are deleted for good after 30 days.
` : '') + + (pinned.length ? `

Pinned

${pinned.map(card).join('')}
${rest.length ? '

Others

' : ''}` : '') + + (rest.length ? `
${rest.map(card).join('')}
` : '') + + (!rows.length ? `
${icon(trash ? 'trash' : 'doc')}

${nt.notes.length || nt.q || nt.tag ? (trash ? 'The trash is empty.' : 'Nothing matches.') : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}

` : ''); + $('#ntgrid').querySelectorAll('[data-restore]').forEach(b => b.onclick = async e => { e.preventDefault(); e.stopPropagation(); try { await ntPost('/', { id: b.dataset.restore, deleted: false }); await ntLoad(); draw(); toast('Restored'); } catch (x) { toast(x.message); } }); + $('#ntgrid').querySelectorAll('[data-purge]').forEach(b => b.onclick = e => { e.preventDefault(); e.stopPropagation(); confirmSheet('Delete for good?', 'This cannot be undone.', 'Delete', async () => { try { await ntApi('/' + b.dataset.purge + '?forever=1', { method: 'DELETE' }); await ntLoad(); draw(); } catch (x) { toast(x.message); } }); }); + const tc = $('#nttrashclr'); if (tc) tc.onclick = () => confirmSheet('Empty the trash?', 'Everything in it is deleted for good.', 'Empty the trash', async () => { try { await ntPost('/empty-trash', {}); await ntLoad(); draw(); } catch (x) { toast(x.message); } }); }; draw(); let t; $('#ntq').oninput = e => { nt.q = e.target.value.trim(); clearTimeout(t); t = setTimeout(draw, 200); }; + $('#nts').onchange = e => { nt.sort = e.target.value; draw(); }; $('#ntf').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); nt.filter = a.dataset.f; $('#ntf').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); }); $('#ntnew').onclick = ntNewSheet; } -function ntNewSheet() { +async function ntNewSheet() { const mk = async b => { try { const n = await ntPost('/', b); closeSheet(); location.hash = '#/notes/' + n.id; } catch (e) { toast(e.message); } }; - const el = openSheet('New', '', `
-

Ready-made lists

${NT_TEMPLATES.map((t, i) => ``).join('')}
`); + let mine = []; try { mine = (await ntApi('/templates')).templates; } catch { /* offline: the built-in ones still work */ } + const el = openSheet('New', '', `
+

Ready-made lists

${NT_TEMPLATES.map((t, i) => ``).join('')}
+ ${mine.length ? `

Your templates

${mine.map(t => `
`).join('')}
` : ''}`); el.querySelector('#ntn').onclick = () => mk({ type: 'note' }); el.querySelector('#ntl').onclick = () => mk({ type: 'list' }); el.querySelectorAll('[data-t]').forEach(b => b.onclick = () => { const t = NT_TEMPLATES[+b.dataset.t]; mk({ type: 'list', title: t[0], items: t[1], share: 'family' }); }); + el.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const t = mine.find(x => x.id === b.dataset.m); mk({ type: 'list', title: t.name, items: t.items, share: 'me' }); }); + el.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { try { await ntPost('/templates/delete', { id: b.dataset.del }); b.closest('.row').remove(); } catch (e) { toast(e.message); } }); } // ================================================================ one note or list +/** A photo from a phone can be 10 MB; shrink big pictures before they are sent. */ +async function ntShrink(file) { + if (!/^image\/(jpeg|png|webp)$/.test(file.type) || file.size < 1.5e6) return file; + try { + const bmp = await createImageBitmap(file), k = Math.min(1, 2000 / Math.max(bmp.width, bmp.height)), c = document.createElement('canvas'); c.width = Math.round(bmp.width * k); c.height = Math.round(bmp.height * k); c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height); + const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.85)); return blob && blob.size < file.size ? new File([blob], file.name.replace(/\.\w+$/, '') + '.jpg', { type: 'image/jpeg' }) : file; + } catch { return file; } +} async function ntEditor(id) { const v = $('#view'); v.innerHTML = '

Loading…

'; let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/notes/')) return; - let saveT, busy = false, dirty = false; + let saveT, busy = false, dirty = false, editing = n.type === 'note' && !n.body && n.canEdit; // a note with text opens as a reading page; tap to edit + const ro = () => !n.canEdit || !!n.deleted; const render = () => { - const list = n.type === 'list'; - v.innerHTML = `
${icon('back')}Notes
- -

${esc(ntWho(n))}${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}

- ${list ? `
${n.items.map(i => `
${esc(i.text)}${i.done && i.byName ? `${esc(i.byName)}` : i.addedBy && n.share !== 'me' ? `${esc(i.addedBy)}` : ''}
`).join('')}
-
-
${ntDone(n)} of ${n.items.length} done${ntDone(n) ? '' : ''}
` - : ``}
`; + const list = n.type === 'list', due = ntDue(null), rd = ro(); + v.innerHTML = `
${icon('back')}Notes${n.canEdit ? `` : ''}
+ ${n.deleted ? `
This is in the trash.
` : ''}${!n.canEdit && !n.deleted ? '
You can read this but not change it.
' : ''} + +

${esc(ntWho(n))}${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}

+
${n.tags.map(t => `#${esc(t)}${rd ? '' : ``}`).join('')}${rd ? '' : ``}${n.remind ? `` : ''}
+ ${list ? listHtml() : noteHtml()} + ${filesHtml()}
`; wire(); }; + const noteHtml = () => editing && !ro() + ? ` + ` + : `
${n.body ? ntMd(n.body) : '

Empty note.

'}
`; + const listHtml = () => `
${n.items.map(i => { const d = ntDue(i.due); return `
${ro() ? '' : `⋮⋮`}${esc(i.text)}${d ? `${esc(d.text)}` : ''}${i.done && i.byName ? `${esc(i.byName)}` : i.addedBy && n.share !== 'me' ? `${esc(i.addedBy)}` : ''}${ro() ? '' : ``}
`; }).join('') || '

No items yet.

'}
+ ${ro() ? '' : `
`} +
${ntDone(n)} of ${n.items.length} done${!ro() && ntDone(n) ? '' : ''}
`; + const filesHtml = () => { + const f = n.files, src = x => `/api/notes/${n.id}/files/${x.id}`; + return `

Photos and files

${ro() ? '' : ``}
+ ${f.length ? `
${f.map(x => /^image\//.test(x.type) ? `
${esc(x.name)}${ro() ? '' : ``}
` : ``).join('')}
` : '

None yet.

'}
`; + }; const wire = () => { - const tt = $('#ntt'); tt.oninput = () => { dirty = true; queue(); }; - if (n.type === 'note') { const b = $('#ntb'); b.oninput = () => { dirty = true; queue(); }; } - $('#ntpin').onclick = () => patch({ pinned: !n.pinned }); $('#ntsh').onclick = e => { e.preventDefault(); ntShareSheet(n, patch); }; $('#ntmore').onclick = () => ntMore(n, patch); - if (n.type === 'list') { - const act = async b => { try { n = await ntPost('/' + n.id + '/items', b); render(); } catch (e) { toast(e.message); } }; - v.querySelectorAll('.ntitem').forEach(r => { const iid = r.dataset.i; - r.querySelector('input[type=checkbox]').onchange = e => act({ toggle: iid, done: e.target.checked }); r.querySelector('[data-x]').onclick = () => act({ remove: iid }); - const tx = r.querySelector('.nttext'), orig = n.items.find(i => i.id === iid).text; tx.onblur = () => { const t = tx.textContent.trim(); if (t && t !== orig) act({ edit: iid, text: t }); else if (!t) tx.textContent = orig; }; tx.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); tx.blur(); } }; }); - $('#ntadd').onsubmit = async e => { e.preventDefault(); const i = $('#ntnewitem'), t = i.value.trim(); if (!t) return; i.value = ''; try { n = await ntPost('/' + n.id + '/items', { text: t }); render(); $('#ntnewitem').focus(); } catch (x) { toast(x.message); i.value = t; } }; - if ($('#ntclr')) $('#ntclr').onclick = () => act({ clearDone: true }); + const tt = $('#ntt'); if (!ro()) tt.oninput = () => { dirty = true; queue(); }; + $('#ntsh').onclick = e => { e.preventDefault(); ntShareSheet(n, patch); }; $('#ntmore').onclick = () => ntMore(n, patch, { editor: ntEditor, reload: () => ntEditor(n.id), setRemind: () => ntRemindSheet(n, patch), history: () => ntHistory(n, async m => { n = m; render(); }) }); + if ($('#ntpin')) $('#ntpin').onclick = () => patch({ pinned: !n.pinned }); if ($('#ntres')) $('#ntres').onclick = () => patch({ deleted: false }); if ($('#ntrem')) $('#ntrem').onclick = () => ntRemindSheet(n, patch); + v.querySelectorAll('[data-rt]').forEach(b => b.onclick = () => patch({ tags: n.tags.filter(t => t !== b.dataset.rt) })); + const ti = $('#nttag'); if (ti) ti.onkeydown = e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const t = ti.value.trim().replace(/^#/, ''); if (t) patch({ tags: [...n.tags, t] }); } }; + if (ti) ti.onblur = () => { const t = ti.value.trim().replace(/^#/, ''); if (t) patch({ tags: [...n.tags, t] }); }; + if (n.type === 'note') wireNote(); else wireList(); + const fi = $('#ntfile'); if (fi) fi.onchange = async () => { for (const f of fi.files) { try { toast('Adding ' + f.name + '…'); const b = await ntShrink(f), r = await fetch(`/api/notes/${n.id}/files?name=${encodeURIComponent(b.name || f.name)}`, { method: 'POST', headers: { 'content-type': b.type || 'application/octet-stream' }, body: b }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not add that'); n = j; } catch (e) { toast(e.message); } } render(); }; + v.querySelectorAll('[data-fd]').forEach(b => b.onclick = e => { e.preventDefault(); confirmSheet('Remove this file?', '', 'Remove', async () => { try { n = await ntPost('/' + n.id + '/files/delete', { id: b.dataset.fd }); render(); } catch (x) { toast(x.message); } }); }); + }; + const wireNote = () => { + const b = $('#ntb'); + if (b) { + b.oninput = () => { dirty = true; queue(); }; if (!n.body) b.focus(); + const wrap = (pre, post, ph) => { const s = b.selectionStart, e = b.selectionEnd, sel = b.value.slice(s, e) || ph; b.setRangeText(pre + sel + post, s, e, 'select'); b.setSelectionRange(s + pre.length, s + pre.length + sel.length); b.focus(); b.oninput(); }; + const lines = pre => { const s = b.value.lastIndexOf('\n', b.selectionStart - 1) + 1, eRaw = b.value.indexOf('\n', b.selectionEnd), e = eRaw < 0 ? b.value.length : eRaw, out = b.value.slice(s, e).split('\n').map(l => pre + l).join('\n'); b.setRangeText(out, s, e, 'end'); b.focus(); b.oninput(); }; + v.querySelectorAll('[data-fmt]').forEach(x => x.onclick = () => ({ b: () => wrap('**', '**', 'bold'), i: () => wrap('*', '*', 'italic'), h: () => lines('## '), ul: () => lines('- '), ck: () => lines('- [ ] '), a: () => wrap('[', '](https://)', 'link text') })[x.dataset.fmt]()); + b.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { const s = b.selectionStart, ls = b.value.lastIndexOf('\n', s - 1) + 1, line = b.value.slice(ls, s), m = /^(\s*(?:[-*+] \[ \] |[-*+] |\d+\. ))/.exec(line); if (m) { e.preventDefault(); if (line.trim() === m[1].trim()) { b.setRangeText('', ls, s, 'end'); } else { const next = /^\s*\d+\. /.test(m[1]) ? m[1].replace(/\d+/, d => +d + 1) : m[1]; b.setRangeText('\n' + next, s, s, 'end'); } b.oninput(); } } }; + $('#ntdone').onclick = async () => { await flush(); editing = false; render(); }; } + const md = $('#ntmd'); if (md) { + md.querySelectorAll('input[data-ln]').forEach(c => c.onchange = async () => { if (ro()) { c.checked = !c.checked; return; } const ls = n.body.split('\n'), i = +c.dataset.ln; ls[i] = ls[i].replace(/\[( |x|X)\]/, c.checked ? '[x]' : '[ ]'); await patch({ body: ls.join('\n'), rev: n.rev }); }); + const go = e => { if (ro() || e.target.closest('a, input')) return; editing = true; render(); const t = $('#ntb'); if (t) { t.focus(); t.setSelectionRange(t.value.length, t.value.length); } }; + md.onclick = go; md.onkeydown = e => { if (e.key === 'Enter') go(e); }; + } + }; + const wireList = () => { + const act = async b => { try { n = await ntPost('/' + n.id + '/items', b); render(); } catch (e) { toast(e.message); } }; + v.querySelectorAll('.ntitem').forEach(r => { const iid = r.dataset.i, item = n.items.find(i => i.id === iid); + r.querySelector('input[type=checkbox]').onchange = e => act({ toggle: iid, done: e.target.checked }); + const tx = r.querySelector('.nttext'), orig = item.text; tx.onblur = () => { if (ro()) return; const t = tx.textContent.trim(); if (t && t !== orig) act({ edit: iid, text: t }); else if (!t) tx.textContent = orig; }; tx.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); tx.blur(); } }; + const mb = r.querySelector('[data-m]'); if (mb) mb.onclick = () => { const i = n.items.indexOf(item), acts = [ + { label: item.due ? 'Change the due date' : 'Set a due date', icon: 'cal', run: () => ntDateSheet(item.due, d => act({ setDue: iid, due: d })) }, + ...(item.due ? [{ label: 'Remove the due date', icon: 'x', run: () => act({ setDue: iid, due: '' }) }] : []), + ...(i > 0 ? [{ label: item.indent ? 'Move out (not a sub-item)' : 'Make a sub-item', icon: 'chev', run: () => act({ indent: iid, level: item.indent ? 0 : 1 }) }, { label: 'Move up', icon: 'arrup', run: () => act({ move: iid, before: n.items[i - 1].id }) }] : []), + ...(i < n.items.length - 1 ? [{ label: 'Move down', icon: 'arrdn', run: () => act({ move: iid, before: (n.items[i + 2] || {}).id || null }) }] : []), + { label: 'Remove', icon: 'trash', danger: true, run: () => act({ remove: iid }) }]; actionSheet(item.text.slice(0, 40), '', acts); }; + const h = r.querySelector('[data-h]'); if (h) h.onpointerdown = e => { // drag by the handle: works with a finger or a mouse + e.preventDefault(); h.setPointerCapture(e.pointerId); r.classList.add('dragging'); const box = $('#ntlist'); + const mv = ev => { const el = document.elementFromPoint(ev.clientX, ev.clientY), over = el && el.closest('.ntitem'); if (over && over !== r && over.parentNode === box) { const rc = over.getBoundingClientRect(); box.insertBefore(r, ev.clientY < rc.top + rc.height / 2 ? over : over.nextSibling); } }; + const up = () => { h.removeEventListener('pointermove', mv); h.removeEventListener('pointerup', up); h.removeEventListener('pointercancel', up); r.classList.remove('dragging'); const nx = r.nextElementSibling, before = nx ? nx.dataset.i : null, was = n.items[n.items.indexOf(item) + 1]; if ((before || null) !== (was ? was.id : null)) act({ move: iid, before }); }; + h.addEventListener('pointermove', mv); h.addEventListener('pointerup', up); h.addEventListener('pointercancel', up); }; + }); + const add = $('#ntadd'); if (add) add.onsubmit = async e => { e.preventDefault(); const i = $('#ntnewitem'), t = i.value.trim(); if (!t) return; i.value = ''; try { n = await ntPost('/' + n.id + '/items', { text: t }); render(); $('#ntnewitem').focus(); } catch (x) { toast(x.message); i.value = t; } }; + if ($('#ntclr')) $('#ntclr').onclick = () => act({ clearDone: true }); if ($('#ntunall')) $('#ntunall').onclick = () => act({ uncheckAll: true }); }; const queue = () => { clearTimeout(saveT); saveT = setTimeout(save, 700); }; async function save() { - if (busy) return queue(); busy = true; const body = { id: n.id, title: $('#ntt') ? $('#ntt').value : n.title, rev: n.rev }; if (n.type === 'note' && $('#ntb')) body.body = $('#ntb').value; - try { n = await ntPost('/', body); dirty = false; } + if (busy) return queue(); if (ro() || !$('#ntt')) return; busy = true; const body = { id: n.id, title: $('#ntt').value, rev: n.rev }; if (n.type === 'note' && $('#ntb')) body.body = $('#ntb').value; + try { const r = await ntPost('/', body); if (r && r.queued) { dirty = false; } else { n = r; dirty = false; } } catch (e) { if (e.status === 409 && e.message) { toast('Someone else changed this just now. Showing their version.'); const j = await fetch('/api/notes').then(r => r.json()).catch(() => null); const fresh = j && j.notes.find(x => x.id === n.id); if (fresh) { n = fresh; dirty = false; render(); } } else toast(e.message); } busy = false; } - const patch = async fields => { try { n = await ntPost('/', { id: n.id, ...fields }); if (location.hash.startsWith('#/notes/')) render(); if (fields.archived) { toast('Archived'); location.hash = '#/notes'; } } catch (e) { toast(e.message); } }; + const flush = async () => { clearTimeout(saveT); if (dirty) await save(); }; + const patch = async fields => { try { const r = await ntPost('/', { id: n.id, ...fields }); if (r && r.queued) return; n = r; if (location.hash.startsWith('#/notes/')) render(); if (fields.archived) { toast('Archived'); location.hash = '#/notes'; } } catch (e) { toast(e.message); } }; render(); // the open note follows what other people do to it nt.timer = setInterval(async () => { if (!location.hash.startsWith('#/notes/') || document.hidden || busy || dirty) return; - const a = document.activeElement; if (a && (a.id === 'ntt' || a.id === 'ntb' || a.classList.contains('nttext') || (a.id === 'ntnewitem' && a.value))) return; // (never while someone is typing; an empty add box does not count) + const a = document.activeElement; if (a && (a.id === 'ntt' || a.id === 'ntb' || a.id === 'nttag' || a.classList.contains('nttext') || (a.id === 'ntnewitem' && a.value))) return; // (never while someone is typing; an empty add box does not count) const refocus = !!(a && a.id === 'ntnewitem'); try { const r = await ntApi('/'); const fresh = r.notes.find(x => x.id === n.id); if (!fresh) { clearInterval(nt.timer); toast('This note was deleted'); location.hash = '#/notes'; return; } if (fresh.rev !== n.rev) { n = fresh; render(); if (refocus && $('#ntnewitem')) $('#ntnewitem').focus(); } } catch { /* try again */ } }, 6000); - addEventListener('hashchange', () => { if (!location.hash.startsWith('#/notes/')) { clearInterval(nt.timer); nt.timer = null; } }, { once: true }); + addEventListener('hashchange', () => { flush(); if (!location.hash.startsWith('#/notes/')) { clearInterval(nt.timer); nt.timer = null; } }, { once: true }); +} +function ntDateSheet(cur, done) { + const t = new Date(), plus = d => { const x = new Date(t); x.setDate(x.getDate() + d); return ntYmd(x); }; + const el = openSheet('Due date', '', `
+
`); + el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { closeSheet(); done(b.dataset.d); }); el.querySelector('#ntdg').onclick = () => { const d = el.querySelector('#ntdp').value; if (d) { closeSheet(); done(d); } }; +} +function ntRemindSheet(n, patch) { + const at = h => { const d = new Date(); d.setDate(d.getDate() + (h > 24 ? 7 : 1)); d.setHours(9, 0, 0, 0); return h === 1 ? Date.now() + 36e5 : d.getTime(); }, local = ms => { const d = new Date(ms - new Date().getTimezoneOffset() * 6e4); return d.toISOString().slice(0, 16); }; + const el = openSheet('Remind me', n.remind ? 'Set for ' + ntRemind(n.remind) : 'You get a notification, and it lands in your Notifications list.', `
+
${n.remind ? '' : ''}
`); + el.querySelectorAll('[data-ms]').forEach(b => b.onclick = () => { closeSheet(); patch({ remind: +b.dataset.ms }); }); el.querySelector('#ntrg').onclick = () => { const t = new Date(el.querySelector('#ntrp').value).getTime(); if (t) { closeSheet(); patch({ remind: t }); } }; + const c = el.querySelector('#ntrc'); if (c) c.onclick = () => { closeSheet(); patch({ remind: 0 }); }; +} +async function ntHistory(n, set) { + let vs; try { vs = (await ntApi('/' + n.id + '/history')).versions; } catch (e) { return toast(e.message); } + const el = openSheet('Earlier versions', 'Saved as you edit. Restoring keeps the current text in this list too.', vs.length ? `
${vs.map(x => ``).join('')}
` : '

No earlier versions yet.

'); + el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => confirmSheet('Go back to this version?', 'The text now is kept as an earlier version.', 'Restore', async () => { try { set(await ntPost('/', { id: n.id, restore: +b.dataset.r })); toast('Restored'); } catch (e) { toast(e.message); } })); } function ntShareSheet(n, patch) { if (!n.mine && ME && ME.role !== 'admin') return toast('Only ' + n.ownerName + ' can change who this is shared with'); - const chosen = new Set(Array.isArray(n.share) ? n.share : []); - const el = openSheet('Who can see this?', 'Anyone it is shared with can edit it.', `
- ${nt.people.length ? `

Or choose people

${nt.people.map(p => ``).join('')}
` : ''}`); - el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { closeSheet(); patch({ share: b.dataset.s }); }); - const ps = el.querySelector('#ntps'); if (ps) ps.onclick = () => { const ids = [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p); closeSheet(); patch({ share: ids.length ? ids : 'me' }); }; + const chosen = new Map(Array.isArray(n.share) ? n.sharedWith.map(p => [p.id, p.ro]) : []); + const el = openSheet('Who can see this?', 'Choose who can read it, and who can change it.', `
+ + ${nt.people.length ? `

Or choose people

${nt.people.map(p => `
`).join('')}
` : ''}`); + el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { closeSheet(); patch({ share: b.dataset.s, ...(b.dataset.s === 'family' ? { familyRo: el.querySelector('#ntfro').checked } : {}) }); }); + const ps = el.querySelector('#ntps'); if (ps) ps.onclick = () => { const ids = [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p), ro = ids.filter(i => el.querySelector(`[data-r="${i}"]`).value === '1'); closeSheet(); patch(ids.length ? { share: ids, ro } : { share: 'me' }); }; } -function ntMore(n, patch) { - const acts = [{ label: 'Colour', icon: 'cog', run: () => { const el = openSheet('Colour', '', `
${Object.entries(NT_COLORS).map(([k, c]) => ``).join('')}
`); el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { closeSheet(); patch({ color: b.dataset.c }); }); } }]; +function ntMore(n, patch, h) { + const acts = []; + if (n.canEdit && !n.deleted) { + acts.push({ label: 'Colour', icon: 'cog', run: () => { const el = openSheet('Colour', '', `
${Object.entries(NT_COLORS).map(([k, c]) => ``).join('')}
`); el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { closeSheet(); patch({ color: b.dataset.c }); }); } }); + acts.push({ label: n.remind ? 'Change the reminder' : 'Remind me', icon: 'bell', run: h.setRemind }); + } + if (n.type === 'note' && n.history) acts.push({ label: 'Earlier versions', icon: 'rew', run: h.history }); + if (n.type === 'list' && n.items.length) acts.push({ label: 'Keep as a template', icon: 'list', run: () => { const el = openSheet('Keep as a template', 'The items (not the ticks) are saved so you can start a fresh copy any time.', `
`); el.querySelector('#nttg').onclick = async () => { try { await ntPost('/templates', { name: el.querySelector('#nttn').value, items: n.items.map(i => i.text) }); closeSheet(); toast('Saved. Find it under New.'); } catch (e) { toast(e.message); } }; } }); + acts.push({ label: 'Copy as text', icon: 'copy', run: async () => { try { await navigator.clipboard.writeText((n.title ? n.title + '\n\n' : '') + (n.type === 'list' ? n.items.map(i => (i.indent ? ' ' : '') + (i.done ? '[x] ' : '[ ] ') + i.text).join('\n') : n.body)); toast('Copied'); } catch { toast('Could not copy'); } } }); if (allowed('chat')) acts.push({ label: 'Share to Chat', icon: 'send', run: () => shareToChat((n.title || 'Note') + (n.type === 'list' ? ': ' + n.items.filter(i => !i.done).map(i => i.text).slice(0, 8).join(', ') : '') + ' ' + location.origin + '/#/notes/' + n.id) }); - if (n.type === 'list' && allowed('tasks') && n.items.some(i => !i.done)) acts.push({ label: 'Send the open items to Tasks', icon: 'check', run: async () => { try { for (const i of n.items.filter(x => !x.done)) await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: i.text, notes: 'From ' + (n.title || 'a checklist'), due: '', done: false } }) }); toast('Sent to Tasks'); } catch (e) { toast(e.message); } } }); - acts.push({ label: 'Archive', icon: 'files', run: () => patch({ archived: true }) }); - if (n.archived) acts[acts.length - 1] = { label: 'Move out of the archive', icon: 'files', run: () => patch({ archived: false }) }; - if (n.mine || (ME && ME.role === 'admin')) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete this ' + (n.type === 'list' ? 'checklist' : 'note') + '?', 'It is deleted for everyone it is shared with.', 'Delete', async () => { try { await jfetch('/api/notes/' + n.id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/notes'; } catch (e) { toast(e.message); } }) }); + if (n.type === 'list' && allowed('tasks') && n.items.some(i => !i.done)) acts.push({ label: 'Send the open items to Tasks', icon: 'check', run: async () => { try { for (const i of n.items.filter(x => !x.done)) await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: i.text, notes: 'From ' + (n.title || 'a checklist'), due: i.due || '', done: false } }) }); toast('Sent to Tasks'); } catch (e) { toast(e.message); } } }); + if (n.mine || (ME && ME.role === 'admin')) { + acts.push(n.archived ? { label: 'Move out of the archive', icon: 'files', run: () => patch({ archived: false }) } : { label: 'Archive', icon: 'files', run: () => patch({ archived: true }) }); + acts.push({ label: n.deleted ? 'Delete for good' : 'Move to the trash', icon: 'trash', danger: true, run: () => confirmSheet(n.deleted ? 'Delete for good?' : 'Move to the trash?', n.deleted ? 'This cannot be undone.' : 'It is removed for everyone it is shared with. You can restore it from the Trash for 30 days.', n.deleted ? 'Delete' : 'Move to the trash', async () => { try { await jfetch('/api/notes/' + n.id + (n.deleted ? '?forever=1' : ''), { method: 'DELETE' }); toast(n.deleted ? 'Deleted' : 'Moved to the trash'); location.hash = '#/notes'; } catch (e) { toast(e.message); } }) }); + } actionSheet(n.title || 'Note', ntWho(n), acts); } diff --git a/public/offline-queue.js b/public/offline-queue.js index 1b03815..d93b081 100644 --- a/public/offline-queue.js +++ b/public/offline-queue.js @@ -1,7 +1,7 @@ 'use strict'; // ---- Changes made with no signal: adding a task, ticking one off, adding to the shopping list and writing a note are kept on this device and sent when the connection is back. // Only these few safe changes are queued (nothing that could clash with what someone else did meanwhile); anything else says it needs a connection. Tasks show the waiting changes. -const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id]]; +const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id || b.title !== undefined || b.body !== undefined], [/^\/api\/notes\/[0-9a-f]{12}\/items$/, b => !!b.toggle || (b.text !== undefined && !b.edit)]]; const offqKey = () => 'offq:' + (typeof ME !== 'undefined' && ME ? ME.id : ''); const offqRead = () => { try { const a = JSON.parse(localStorage.getItem(offqKey())); return Array.isArray(a) ? a : []; } catch { return []; } }; const offqWrite = l => { try { if (l.length) localStorage.setItem(offqKey(), JSON.stringify(l)); else localStorage.removeItem(offqKey()); } catch { /* storage full or blocked */ } offqNote(); }; diff --git a/public/style.css b/public/style.css index c7ddc6b..5d84d84 100644 --- a/public/style.css +++ b/public/style.css @@ -1039,13 +1039,35 @@ body.vizopen { overflow: hidden; } .annc .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent-t); flex: none; } .annc small { display: block; color: var(--muted); } .annc b { display: block; font-size: 17px; overflow-wrap: anywhere; } /* Notes */ +.ntwrap .ptop h1, .ntedit .ptop h1 { width: auto; } .ntedit .ptop .btn { margin-left: 0; } .ntedit .ptop { margin-top: 4px; } +.ntwrap .ntsearch { display: flex; gap: 8px; margin: 2px 0 4px; } .ntsearch input { flex: 1; min-width: 0; } .ntsearch select { width: auto; max-width: 42%; } +.ntf { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 8px 0; } .ntf::-webkit-scrollbar { display: none; } .ntf a { flex: none; min-height: 36px; } +.ntags { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 8px; } .ntags:empty { display: none; } +.ntag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 13px; text-decoration: none; min-height: 26px; } +.ntags .ntag { cursor: pointer; } .ntag.on { background: var(--accent); color: var(--accent-t); } .ntag button { background: none; border: 0; color: inherit; padding: 0 0 0 2px; font-size: 16px; line-height: 1; cursor: pointer; } +.nth { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 14px 2px 8px; } .ntgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 4px; } -.ntcard { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 14px; background: color-mix(in srgb, var(--nc, var(--line)) 12%, var(--panel)); border: 1px solid var(--line); border-top: 4px solid var(--nc, var(--line)); color: inherit; text-decoration: none; min-height: 110px; } -.ntcard .nttop { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } .ntcard .nttop svg { width: 16px; height: 16px; color: var(--accent); flex: none; } .ntcard p { margin: 0; white-space: pre-wrap; overflow: hidden; max-height: 5.5em; color: var(--muted); } +.ntcard { display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 16px; background: color-mix(in srgb, var(--nc, var(--line)) 10%, var(--panel)); border: 1px solid var(--line); border-top: 4px solid var(--nc, var(--line)); color: inherit; text-decoration: none; min-height: 110px; transition: transform .12s, box-shadow .12s; } +.ntcard:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.18); } .ntcard b { overflow-wrap: anywhere; } +.ntcard .nttop { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } .ntcard .nttop svg { width: 16px; height: 16px; color: var(--accent); flex: none; } .ntcard p { margin: 0; white-space: pre-wrap; overflow: hidden; max-height: 5.5em; color: var(--muted); overflow-wrap: anywhere; } +.ntcard mark, .ntmd mark { background: color-mix(in srgb, var(--accent) 35%, transparent); color: inherit; border-radius: 3px; } .ntitems { display: flex; flex-direction: column; gap: 2px; } .ntitems span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ntitems span.done { text-decoration: line-through; color: var(--muted); } .ntmeta { margin-top: auto; color: var(--muted); } -.ntedit { border-top: 5px solid var(--nc, var(--line)); padding-top: 8px; } .nttitle { width: 100%; font-size: 24px; font-weight: 700; background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 8px 2px; } -.ntbody { width: 100%; min-height: 55vh; font: inherit; line-height: 1.6; resize: vertical; } -.ntitem { display: flex; align-items: center; gap: 10px; padding: 6px 2px; border-bottom: 1px solid var(--line); } .ntitem input[type=checkbox] { width: 22px; height: 22px; flex: none; } .ntitem .nttext { flex: 1; outline: 0; padding: 4px 2px; overflow-wrap: anywhere; } .ntitem.done .nttext { text-decoration: line-through; color: var(--muted); } .ntitem small { color: var(--muted); } +.ntprog { height: 5px; border-radius: 3px; background: var(--panel2); overflow: hidden; } .ntprog i { display: block; height: 100%; background: var(--accent); border-radius: 3px; } +.ntbadges { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; } .ntb, .ntdue { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); } .ntb svg, .ntdue svg { width: 13px; height: 13px; } +.ntdue { padding: 1px 8px; border-radius: 999px; background: var(--panel2); } .ntdue.today { background: color-mix(in srgb, #e5b800 30%, var(--panel2)); color: var(--text); } .ntdue.over { background: color-mix(in srgb, #e5484d 30%, var(--panel2)); color: var(--text); } +.ntempty { text-align: center; color: var(--muted); padding: 40px 10px; } .ntempty svg { width: 42px; height: 42px; opacity: .5; } +.ntedit { border-top: 5px solid var(--nc, var(--line)); padding-top: 8px; } .nttitle { width: 100%; font-size: 26px; font-weight: 700; background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 8px 2px; } +.ntbanner { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); border-radius: 10px; padding: 10px 12px; margin: 8px 0; display: flex; gap: 10px; align-items: center; justify-content: space-between; } +.ntmetaRow { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; } .ntmetaRow .nttaginp { width: 96px; flex: none; min-height: 28px; padding: 2px 10px; border-radius: 999px; font-size: 13px; } .ntremind { display: inline-flex; gap: 5px; align-items: center; border: 0; border-radius: 999px; padding: 3px 11px; background: var(--panel2); color: var(--accent); font-size: 13px; cursor: pointer; } .ntremind svg { width: 14px; height: 14px; } +.ntbody { width: 100%; min-height: 50vh; font: inherit; line-height: 1.6; resize: vertical; } +.nttool { display: flex; gap: 4px; align-items: center; position: sticky; top: 0; z-index: 2; background: var(--bg); padding: 6px 0; border-bottom: 1px solid var(--line); margin-bottom: 8px; } .nttool button { min-width: 40px; min-height: 40px; border-radius: 10px; border: 0; background: var(--panel2); color: var(--text); cursor: pointer; font-size: 15px; } .nttool svg { width: 16px; height: 16px; } .nttool .ntdonebtn { background: var(--accent); color: var(--accent-t); padding: 0 16px; font-weight: 600; } +.ntmd { line-height: 1.65; min-height: 30vh; overflow-wrap: anywhere; cursor: text; } .ntmd p { margin: 0 0 .8em; } .ntmd h2, .ntmd h3, .ntmd h4 { margin: 1em 0 .4em; } .ntmd blockquote { margin: 0 0 .8em; padding: 2px 12px; border-left: 3px solid var(--line); color: var(--muted); } .ntmd code { background: var(--panel2); padding: 1px 5px; border-radius: 5px; } .ntmd pre { background: var(--panel2); padding: 10px 12px; border-radius: 10px; overflow-x: auto; } .ntmd pre code { padding: 0; background: none; } .ntmd a { color: var(--accent); } .ntmd ul, .ntmd ol { padding-left: 1.4em; margin: 0 0 .8em; } .ntmd ul.ntchk { list-style: none; padding-left: 0; } .ntchk li { display: flex; gap: 10px; align-items: flex-start; padding: 3px 0; } .ntchk input { width: 20px; height: 20px; margin-top: 3px; flex: none; } .ntchk li.done span { text-decoration: line-through; color: var(--muted); } +.ntitem { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); background: var(--bg); } .ntitem.sub { margin-left: 28px; } .ntitem input[type=checkbox] { width: 22px; height: 22px; flex: none; } .ntitem .nttext { flex: 1; outline: 0; padding: 4px 2px; overflow-wrap: anywhere; } .ntitem.done .nttext { text-decoration: line-through; color: var(--muted); } .ntitem small { color: var(--muted); } .ntitem.dragging { box-shadow: 0 6px 20px rgba(0,0,0,.35); border-radius: 10px; background: var(--panel); position: relative; z-index: 3; } +.nthandle { color: var(--muted); cursor: grab; touch-action: none; user-select: none; padding: 8px 2px; letter-spacing: -3px; font-size: 14px; } +.ntaddrow { display: flex; gap: 8px; margin: 10px 0; position: sticky; bottom: calc(var(--navh, 0px) + 6px); } .ntaddrow input { flex: 1; } .ntfoot { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 6px 0 14px; } +.ntfiles { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 10px; } .ntfhead { display: flex; align-items: center; justify-content: space-between; } .ntfhead h2 { margin: 0; font-size: 16px; } .ntfhead label.btn { cursor: pointer; } +.ntfgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-top: 8px; } .ntf1 { position: relative; border-radius: 12px; overflow: hidden; background: var(--panel2); aspect-ratio: 1; } .ntf1 img { width: 100%; height: 100%; object-fit: cover; display: block; } .ntf1.doc a { display: flex; flex-direction: column; gap: 4px; align-items: center; justify-content: center; height: 100%; padding: 8px; text-align: center; color: inherit; text-decoration: none; font-size: 13px; overflow-wrap: anywhere; } +.ntfx { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; border-radius: 50%; border: 0; background: rgba(0,0,0,.6); color: #fff; font-size: 18px; line-height: 1; cursor: pointer; } .ntsw { display: flex; gap: 10px; flex-wrap: wrap; } .ntsw1 { width: 44px; height: 44px; border-radius: 50%; border: 2px solid var(--line); cursor: pointer; color: #fff; font-weight: 700; } /* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */ diff --git a/push.js b/push.js index 8aed270..eab787e 100644 --- a/push.js +++ b/push.js @@ -71,6 +71,7 @@ let lastDigest = 0, lastEps = 0; async function tick(now = new Date(), force = false) { if (now.getTime() - lastDigest >= 10 * 60e3) { lastDigest = now.getTime(); try { require('./activity').weeklyDigest(now); } catch (e) { console.warn('[push] weekly summary failed:', e.message); } } // (each child's week, to the parents, on Monday morning) if (now.getTime() - lastEps >= 30 * 60e3) { lastEps = now.getTime(); try { await require('./automations').checkEpisodes(n => require('./podcast').recentEpisodes(n)); } catch (e) { console.warn('[push] new episodes check failed:', e.message); } } + try { for (const r of require('./notes').dueReminders(now.getTime())) { const payload = { title: r.title, body: 'Reminder from Notes', url: '#/notes/' + r.id, tag: 'note:' + r.id }; require('./inbox').add(r.uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); await notify(r.uid, payload).catch(() => {}); } } catch (e) { console.warn('[push] note reminders failed:', e.message); } const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3; if (checkEvents) lastEvents = now.getTime(); for (const u of users.all()) {