Files
hub/public/notes-view.js
T

227 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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. 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'), '<mark>$1</mark>') : html;
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 = '<div class="wrap"><h1>Notes</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived'], ['trash', 'Trash']];
v.innerHTML = `<div class="wrap ntwrap"><div class="ptop"><h1>Notes</h1><span class="pspace grow"></span><button class="btn" id="ntnew">${icon('plus')}New</button></div>
<div class="ntsearch"><input type="search" id="ntq" placeholder="Search notes and #tags" value="${esc(nt.q)}" autocomplete="off" aria-label="Search notes"><select id="nts" aria-label="Sort"><option value="updated">Recent</option><option value="created">Newest</option><option value="title">A–Z</option></select></div>
<div class="seg ntf" id="ntf">${filters.map(([k, l]) => `<a href="#" data-f="${k}" class="${nt.filter === k ? 'on' : ''}">${l}</a>`).join('')}</div><div class="ntags" id="ntags"></div><div id="ntgrid"></div></div>`;
$('#nts').value = nt.sort;
const draw = () => {
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]) => `<a href="#" data-t="${esc(t)}" class="ntag ${nt.tag === t ? 'on' : ''}">#${esc(t)}</a>`).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 `<a class="ntcard" href="#/notes/${n.id}" data-id="${n.id}" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="nttop"><b>${ntMark(esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note')), q)}</b>${n.pinned ? icon('bookmark') : ''}</div>
${n.type === 'list' ? `<div class="ntitems">${n.items.slice(0, 5).map(i => `<span class="${i.done ? 'done' : ''}">${i.done ? '☑' : '☐'} ${ntMark(esc(i.text), q)}</span>`).join('')}${n.items.length > 5 ? `<small>+ ${n.items.length - 5} more</small>` : ''}</div>${n.items.length ? `<div class="ntprog" title="${ntDone(n)} of ${n.items.length} done"><i style="width:${pct}%"></i></div>` : ''}`
: `<p>${ntMark(esc(ntSnippet(n.body, q)), q)}</p>`}
${n.tags.length || due || n.remind || n.files.length || n.canEdit === false ? `<div class="ntbadges">${n.tags.slice(0, 3).map(t => `<span class="ntag">#${esc(t)}</span>`).join('')}${due ? `<span class="ntdue ${due.cls}">${icon('cal')}${esc(due.text)}</span>` : ''}${n.remind ? `<span class="ntb">${icon('bell')}</span>` : ''}${n.files.length ? `<span class="ntb">${icon('doc')}${n.files.length}</span>` : ''}${n.canEdit === false ? '<span class="ntb">View only</span>' : ''}</div>` : ''}
<small class="ntmeta">${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}</small>${trash ? `<div class="bar" style="margin:6px 0 0"><button class="btn ghost" data-restore="${n.id}">Restore</button><button class="btn ghost" data-purge="${n.id}">Delete for good</button></div>` : ''}</a>`;
};
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 ? `<div class="bar" style="justify-content:space-between;align-items:center"><small class="sub">Notes here are deleted for good after 30 days.</small><button class="btn ghost" id="nttrashclr">Empty the trash</button></div>` : '')
+ (pinned.length ? `<h2 class="nth">Pinned</h2><div class="ntgrid">${pinned.map(card).join('')}</div>${rest.length ? '<h2 class="nth">Others</h2>' : ''}` : '')
+ (rest.length ? `<div class="ntgrid">${rest.map(card).join('')}</div>` : '')
+ (!rows.length ? `<div class="ntempty">${icon(trash ? 'trash' : 'doc')}<p>${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.'}</p></div>` : '');
$('#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', '', `<div class="list"><button class="row" id="ntn">${icon('doc')}<span class="grow"><b>Note</b><small>Writing, with Markdown</small></span></button><button class="row" id="ntl">${icon('check')}<span class="grow"><b>Checklist</b><small>Items to tick off</small></span></button></div>
<h2>Ready-made lists</h2><div class="list">${NT_TEMPLATES.map((t, i) => `<button class="row" data-t="${i}">${icon('list')}<span class="grow"><b>${esc(t[0])}</b><small>${esc(t[1].join(', '))}</small></span></button>`).join('')}</div>
${mine.length ? `<h2>Your templates</h2><div class="list">${mine.map(t => `<div class="row"><button class="grow" style="text-align:left;background:none;border:0;color:inherit;display:flex;gap:12px;align-items:center" data-m="${esc(t.id)}">${icon('list')}<span class="grow"><b>${esc(t.name)}</b><small>${esc(t.items.slice(0, 6).join(', '))}</small></span></button><button class="ibtn" data-del="${esc(t.id)}" aria-label="Delete the template ${esc(t.name)}">${icon('trash')}</button></div>`).join('')}</div>` : ''}`);
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 = '<div class="wrap"><p class="empty loading" role="status">Loading…</p></div>';
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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes/')) return;
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', due = ntDue(null), rd = ro();
v.innerHTML = `<div class="wrap ntedit" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="ptop"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a><span class="pspace grow"></span>${n.canEdit ? `<button class="ibtn" id="ntpin" aria-label="${n.pinned ? 'Unpin' : 'Pin'}" title="${n.pinned ? 'Unpin' : 'Pin to the top'}" style="${n.pinned ? 'color:var(--accent)' : ''}">${icon('bookmark')}</button>` : ''}<button class="ibtn" id="ntmore" aria-label="More">${icon('more')}</button></div>
${n.deleted ? `<div class="ntbanner">This is in the trash. <button class="btn ghost" id="ntres">Restore</button></div>` : ''}${!n.canEdit && !n.deleted ? '<div class="ntbanner">You can read this but not change it.</div>' : ''}
<input id="ntt" class="nttitle" type="text" value="${esc(n.title)}" placeholder="${list ? 'Checklist' : 'Title'}" maxlength="120" autocomplete="off" ${rd ? 'readonly' : ''}>
<p class="sub" style="margin:4px 0 8px"><a href="#" id="ntsh" style="color:var(--accent)">${esc(ntWho(n))}</a>${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}</p>
<div class="ntmetaRow">${n.tags.map(t => `<span class="ntag">#${esc(t)}${rd ? '' : `<button data-rt="${esc(t)}" aria-label="Remove the tag ${esc(t)}">×</button>`}</span>`).join('')}${rd ? '' : `<input id="nttag" class="nttaginp" type="text" placeholder="+ tag" maxlength="24" autocomplete="off" autocapitalize="off" aria-label="Add a tag">`}${n.remind ? `<button class="ntremind" id="ntrem">${icon('bell')}${esc(ntRemind(n.remind))}</button>` : ''}</div>
${list ? listHtml() : noteHtml()}
${filesHtml()}</div>`;
wire();
};
const noteHtml = () => editing && !ro()
? `<div class="nttool" role="toolbar" aria-label="Format">${[['b', '<b>B</b>', 'Bold'], ['i', '<i>I</i>', 'Italic'], ['h', 'H', 'Heading'], ['ul', '•', 'Bullet list'], ['ck', '☐', 'Tick boxes'], ['a', icon('link'), 'Link']].map(([k, l, t]) => `<button data-fmt="${k}" aria-label="${t}" title="${t}">${l}</button>`).join('')}<span class="grow"></span><button id="ntdone" class="ntdonebtn">Done</button></div>
<textarea id="ntb" class="ntbody" placeholder="Write here. Markdown works: **bold**, # heading, - [ ] tick box" maxlength="50000">${esc(n.body)}</textarea>`
: `<div class="ntmd" id="ntmd" ${ro() ? '' : 'tabindex="0" role="button" aria-label="Tap to edit"'}>${n.body ? ntMd(n.body) : '<p class="sub">Empty note.</p>'}</div>`;
const listHtml = () => `<div class="ntlist" id="ntlist">${n.items.map(i => { const d = ntDue(i.due); return `<div class="ntitem ${i.done ? 'done' : ''} ${i.indent ? 'sub' : ''}" data-i="${i.id}">${ro() ? '' : `<span class="nthandle" data-h aria-label="Drag to move" title="Drag to move">⋮⋮</span>`}<input type="checkbox" ${i.done ? 'checked' : ''} ${ro() ? 'disabled' : ''} aria-label="Done"><span class="nttext" ${ro() ? '' : 'contenteditable="plaintext-only"'} spellcheck="false">${esc(i.text)}</span>${d ? `<span class="ntdue ${d.cls}">${esc(d.text)}</span>` : ''}${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : i.addedBy && n.share !== 'me' ? `<small>${esc(i.addedBy)}</small>` : ''}${ro() ? '' : `<button class="ibtn" data-m aria-label="Item options">${icon('more')}</button>`}</div>`; }).join('') || '<p class="sub">No items yet.</p>'}</div>
${ro() ? '' : `<form id="ntadd" class="ntaddrow"><input type="text" id="ntnewitem" placeholder="Add an item" maxlength="300" autocomplete="off" enterkeyhint="done"><button class="btn">Add</button></form>`}
<div class="ntfoot"><small class="sub">${ntDone(n)} of ${n.items.length} done</small><span class="grow"></span>${!ro() && ntDone(n) ? '<button class="btn ghost" id="ntunall">Uncheck all</button><button class="btn ghost" id="ntclr">Clear the done ones</button>' : ''}</div>`;
const filesHtml = () => {
const f = n.files, src = x => `/api/notes/${n.id}/files/${x.id}`;
return `<div class="ntfiles"><div class="ntfhead"><h2>Photos and files</h2>${ro() ? '' : `<label class="btn ghost" for="ntfile">${icon('plus')}Add</label><input type="file" id="ntfile" accept="image/*,application/pdf,text/plain" multiple hidden>`}</div>
${f.length ? `<div class="ntfgrid">${f.map(x => /^image\//.test(x.type) ? `<div class="ntf1"><a href="${src(x)}" target="_blank" rel="noopener"><img src="${src(x)}" alt="${esc(x.name)}" loading="lazy"></a>${ro() ? '' : `<button class="ntfx" data-fd="${x.id}" aria-label="Remove ${esc(x.name)}">×</button>`}</div>` : `<div class="ntf1 doc"><a href="${src(x)}" target="_blank" rel="noopener">${icon('doc')}<span>${esc(x.name)}</span><small>${Math.max(1, Math.round(x.size / 1024))} KB</small></a>${ro() ? '' : `<button class="ntfx" data-fd="${x.id}" aria-label="Remove ${esc(x.name)}">×</button>`}</div>`).join('')}</div>` : '<p class="sub" style="margin:4px 0">None yet.</p>'}</div>`;
};
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', '', `<div class="list"><button class="row" data-d="${plus(0)}"><span class="grow"><b>Today</b></span></button><button class="row" data-d="${plus(1)}"><span class="grow"><b>Tomorrow</b></span></button><button class="row" data-d="${plus(7)}"><span class="grow"><b>In a week</b></span></button></div>
<label for="ntdp" style="margin-top:12px;display:block">Or pick a day</label><input type="date" id="ntdp" value="${esc(cur || '')}" style="width:100%"><div class="bar"><button class="btn" id="ntdg" style="width:100%;justify-content:center">Set</button></div>`);
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.', `<div class="list"><button class="row" data-ms="${at(1)}"><span class="grow"><b>In an hour</b></span></button><button class="row" data-ms="${at(24)}"><span class="grow"><b>Tomorrow at 9</b></span></button><button class="row" data-ms="${at(168)}"><span class="grow"><b>Next week at 9</b></span></button></div>
<label for="ntrp" style="margin-top:12px;display:block">Or pick a time</label><input type="datetime-local" id="ntrp" value="${local(Date.now() + 36e5)}" style="width:100%"><div class="bar"><button class="btn" id="ntrg" style="flex:1;justify-content:center">Set</button>${n.remind ? '<button class="btn ghost" id="ntrc" style="flex:1;justify-content:center">Remove</button>' : ''}</div>`);
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 ? `<div class="list">${vs.map(x => `<button class="row" data-r="${x.i}"><span class="grow"><b>${esc(x.title || 'Untitled')}</b><small>${esc(x.by)} · ${esc(sgTime(x.t))}</small><small>${esc(x.snippet)}</small></span></button>`).join('')}</div>` : '<p class="empty">No earlier versions yet.</p>');
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.', `<div class="list"><button class="row" data-s="me"><span class="grow"><b>Just me</b></span>${n.share === 'me' ? icon('check') : ''}</button><button class="row" data-s="family"><span class="grow"><b>The whole family</b><small>Everyone with Notes</small></span>${n.share === 'family' ? icon('check') : ''}</button></div>
<label style="display:flex;gap:8px;align-items:center;margin:8px 2px"><input type="checkbox" id="ntfro" ${n.familyRo ? 'checked' : ''}>Family can only view</label>
${nt.people.length ? `<h2>Or choose people</h2><div class="list">${nt.people.map(p => `<div class="row"><label class="grow" style="display:flex;gap:10px;align-items:center;cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><b>${esc(p.name)}</b></label><select data-r="${esc(p.id)}" aria-label="${esc(p.name)} can"><option value="0">Can edit</option><option value="1" ${chosen.get(p.id) ? 'selected' : ''}>View only</option></select></div>`).join('')}</div><div class="bar"><button class="btn" id="ntps" style="width:100%;justify-content:center">Share with these people</button></div>` : ''}`);
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, h) {
const acts = [];
if (n.canEdit && !n.deleted) {
acts.push({ label: 'Colour', icon: 'cog', run: () => { const el = openSheet('Colour', '', `<div class="ntsw">${Object.entries(NT_COLORS).map(([k, c]) => `<button class="ntsw1" data-c="${k}" style="background:${c}" aria-label="${k || 'none'}">${n.color === k ? '✓' : ''}</button>`).join('')}</div>`); 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.', `<input type="text" id="nttn" value="${esc(n.title || 'My list')}" maxlength="60" style="width:100%"><div class="bar"><button class="btn" id="nttg" style="width:100%;justify-content:center">Save template</button></div>`); 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: 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);
}