'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 }; 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; function viewNotes(args = []) { clearInterval(nt.timer); nt.timer = null; return args[0] ? ntEditor(args[0]) : ntHome(); } async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.people = r.people; return r; } // ================================================================ the list of notes 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 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.'}

`; }; draw(); let t; $('#ntq').oninput = e => { nt.q = e.target.value.trim(); clearTimeout(t); t = setTimeout(draw, 200); }; $('#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() { 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('')}
`); 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' }); }); } // ================================================================ one note or list 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 = `
${icon('back')}Notes

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/notes/')) return; let saveT, busy = false, dirty = false; 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)}` : ''}
`).join('')}
${ntDone(n)} of ${n.items.length} done${ntDone(n) ? '' : ''}
` : ``}
`; wire(); }; 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 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; } 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); } }; 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 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 }); } 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' }); }; } 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 }); }); } }]; 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); } }) }); actionSheet(n.title || 'Note', ntWho(n), acts); }