Notes tab: shared and private notes and checklists; icon-only menu rail on short windows
build-container / build (push) Successful in 6m21s
build-container / build (push) Successful in 6m21s
- notes.js and the Notes tab: private, family or chosen-people sharing; checklist items change one at a time (no overwrites), with who ticked them; open lists refresh live; stale text edits are refused - Pin, colour, archive, search, filters, ready-made lists, Share to Chat, send open items to Tasks - The narrow menu rail shows icons only (names on hover) when the window is too short for labels - Roadmap and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41aa4e676d
commit
cf292e3bc7
12 files changed
+236
-12
No files matched your search
+8
-7
@@ -168,8 +168,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', notes: 'doc', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
|
||||
@@ -179,14 +179,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
|
||||
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
|
||||
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
|
||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
|
||||
const navGroups = (tabs = navTabs()) => {
|
||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||
return out.filter(g => g.tabs.length);
|
||||
};
|
||||
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', notes: '#e5b800', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
|
||||
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
|
||||
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
|
||||
@@ -200,7 +200,7 @@ function renderNav(active = activeTab) {
|
||||
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
||||
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
||||
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||
const mine = navTabs();
|
||||
if (phone.matches) {
|
||||
@@ -256,7 +256,7 @@ function drawSubnav(name) {
|
||||
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||
let curRoute = '';
|
||||
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
@@ -324,6 +324,7 @@ function homeApps() {
|
||||
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
||||
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
||||
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
||||
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
|
||||
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
|
||||
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
|
||||
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
|
||||
@@ -335,7 +336,7 @@ function homeApps() {
|
||||
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
||||
};
|
||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||
const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||
const HOME_GROUPS = [['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/chat-view.js"></script>
|
||||
<script src="/notes-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'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 = '<div class="wrap"><h1>Notes</h1><p class="empty">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']];
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Notes</h1><span class="pspace grow"></span><button class="btn" id="ntnew">${icon('plus')}New</button></div>
|
||||
<input type="search" id="ntq" placeholder="Search notes and lists" value="${esc(nt.q)}" autocomplete="off" aria-label="Search notes" style="width:100%"><div class="seg" id="ntf">${filters.map(([k, l]) => `<a href="#" data-f="${k}" class="${nt.filter === k ? 'on' : ''}">${l}</a>`).join('')}</div><div id="ntgrid" class="ntgrid"></div></div>`;
|
||||
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 => `<a class="ntcard" href="#/notes/${n.id}" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="nttop"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}</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 ? '☑' : '☐'} ${esc(i.text)}</span>`).join('')}${n.items.length > 5 ? `<small>+ ${n.items.length - 5} more</small>` : ''}</div><small>${ntDone(n)} of ${n.items.length} done</small>` : `<p>${esc(n.body.slice(0, 180))}</p>`}
|
||||
<small class="ntmeta">${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}</small></a>`).join('') || `<p class="empty">${nt.notes.length ? 'Nothing matches.' : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}</p>`;
|
||||
};
|
||||
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', '', `<div class="list"><button class="row" id="ntn">${icon('doc')}<span class="grow"><b>Note</b><small>Free writing</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>`);
|
||||
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 = '<div class="wrap"><p class="empty">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;
|
||||
const render = () => {
|
||||
const list = n.type === 'list';
|
||||
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><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>
|
||||
<input id="ntt" class="nttitle" type="text" value="${esc(n.title)}" placeholder="${list ? 'Checklist' : 'Title'}" maxlength="120" autocomplete="off">
|
||||
<p class="sub" style="margin:4px 0 12px"><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>
|
||||
${list ? `<div class="ntlist">${n.items.map(i => `<div class="ntitem ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" ${i.done ? 'checked' : ''} aria-label="Done"><span class="nttext" contenteditable="plaintext-only" spellcheck="false">${esc(i.text)}</span>${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : ''}<button class="ibtn" data-x aria-label="Remove">${icon('x')}</button></div>`).join('')}</div>
|
||||
<form id="ntadd" class="bar" style="margin:8px 0;gap:8px"><input type="text" id="ntnewitem" placeholder="Add an item" maxlength="300" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
||||
<div class="bar" style="justify-content:space-between"><small class="sub">${ntDone(n)} of ${n.items.length} done</small>${ntDone(n) ? '<button class="btn ghost" id="ntclr">Clear the done ones</button>' : ''}</div>`
|
||||
: `<textarea id="ntb" class="ntbody" placeholder="Write here" maxlength="50000">${esc(n.body)}</textarea>`}</div>`;
|
||||
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.', `<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>
|
||||
${nt.people.length ? `<h2>Or choose people</h2><div class="list">${nt.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).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 }); });
|
||||
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', '', `<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 }); }); } }];
|
||||
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);
|
||||
}
|
||||
@@ -1021,3 +1021,17 @@ body.vizopen { overflow: hidden; }
|
||||
|
||||
.annc { display: flex; align-items: center; gap: 12px; margin: 0 0 14px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--accent) 14%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); }
|
||||
.annc .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent-t); flex: none; } .annc small { display: block; color: var(--muted); } .annc b { display: block; font-size: 17px; overflow-wrap: anywhere; }
|
||||
|
||||
/* Notes */
|
||||
.ntgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 4px; }
|
||||
.ntcard { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 14px; background: color-mix(in srgb, var(--nc, var(--line)) 12%, var(--panel)); border: 1px solid var(--line); border-top: 4px solid var(--nc, var(--line)); color: inherit; text-decoration: none; min-height: 110px; }
|
||||
.ntcard .nttop { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } .ntcard .nttop svg { width: 16px; height: 16px; color: var(--accent); flex: none; } .ntcard p { margin: 0; white-space: pre-wrap; overflow: hidden; max-height: 5.5em; color: var(--muted); }
|
||||
.ntitems { display: flex; flex-direction: column; gap: 2px; } .ntitems span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ntitems span.done { text-decoration: line-through; color: var(--muted); } .ntmeta { margin-top: auto; color: var(--muted); }
|
||||
.ntedit { border-top: 5px solid var(--nc, var(--line)); padding-top: 8px; } .nttitle { width: 100%; font-size: 24px; font-weight: 700; background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 8px 2px; }
|
||||
.ntbody { width: 100%; min-height: 55vh; font: inherit; line-height: 1.6; resize: vertical; }
|
||||
.ntitem { display: flex; align-items: center; gap: 10px; padding: 6px 2px; border-bottom: 1px solid var(--line); } .ntitem input[type=checkbox] { width: 22px; height: 22px; flex: none; } .ntitem .nttext { flex: 1; outline: 0; padding: 4px 2px; overflow-wrap: anywhere; } .ntitem.done .nttext { text-decoration: line-through; color: var(--muted); } .ntitem small { color: var(--muted); }
|
||||
.ntsw { display: flex; gap: 10px; flex-wrap: wrap; } .ntsw1 { width: 44px; height: 44px; border-radius: 50%; border: 2px solid var(--line); cursor: pointer; color: #fff; font-weight: 700; }
|
||||
|
||||
/* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */
|
||||
@media (min-width: 768px) and (max-width: 1023px) and (max-height: 1100px) { #nav a > span:not(.nbadge), #nav .ngrp span { display: none; } #nav a { min-height: 34px; justify-content: center; } #nav a svg { width: 22px; height: 22px; } #nav .ngrp { margin-top: 3px; padding-top: 3px; } #nav { gap: 1px; } }
|
||||
@media (min-width: 1024px) and (max-height: 1100px) { body.nav-mini #nav a > span:not(.nbadge), body.nav-mini #nav .ngrp span { display: none; } body.nav-mini #nav a { min-height: 34px; justify-content: center; } body.nav-mini #nav a svg { width: 22px; height: 22px; } body.nav-mini #nav { gap: 1px; } }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v62';
|
||||
const CACHE = 'hub-v64';
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user