${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;
}
async function ntNewSheet() {
const mk = async b => { try { const n = await ntPost('/', b); closeSheet(); location.hash = '#/notes/' + n.id; } catch (e) { toast(e.message); } };
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 = `
`;
};
const wire = () => {
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(); 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 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.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', () => { 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 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 ? `