build-container / build (push) Successful in 6m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
36 KiB
JavaScript
227 lines
36 KiB
JavaScript
'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);
|
||
}
|