'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 = '
';
try { await ntLoad(); } catch (e) { v.innerHTML = ``; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
v.innerHTML = `
Notes ${icon('plus')}New
${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', '', `${icon('doc')}Note Free writing ${icon('check')}Checklist Items to tick off
Ready-made lists ${NT_TEMPLATES.map((t, i) => `${icon('list')}${esc(t[0])} ${esc(t[1].join(', '))} `).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 = '';
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 = ``; return; }
if (!location.hash.startsWith('#/notes/')) return;
let saveT, busy = false, dirty = false;
const render = () => {
const list = n.type === 'list';
v.innerHTML = `
${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)} ` : ''}${icon('x')}
`).join('')}
${ntDone(n)} of ${n.items.length} done ${ntDone(n) ? 'Clear the done ones ' : ''}
`
: `
`}
`;
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.', `Just me ${n.share === 'me' ? icon('check') : ''}The whole family Everyone with Notes ${n.share === 'family' ? icon('check') : ''}
${nt.people.length ? `Or choose people ${nt.people.map(p => `${esc(p.name)} `).join('')}
Share with these people
` : ''}`);
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]) => `${n.color === k ? '✓' : ''} `).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);
}