diff --git a/public/app.js b/public/app.js index a774e90..faa87f9 100644 --- a/public/app.js +++ b/public/app.js @@ -246,6 +246,7 @@ function viewHome() { const t = typeof cur === 'function' ? cur() : null; $('#view').innerHTML = `

${greet}

${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} +
${allowed('music') ? '
' : ''}
@@ -270,6 +271,7 @@ function viewHome() { $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); + if ($('#qadd')) { $('#qadd').onclick = () => quickAdd(); $('#qpal').onclick = () => openPalette(); } if (allowed('tasks')) renderTasksCard($('#tcard')); if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today'); } @@ -326,6 +328,7 @@ function viewSearch() {
`; const q = $('#q'); q.focus(); let timer, seq = 0, plexSt = null; + if (window.pendingSearch) { q.value = window.pendingSearch; window.pendingSearch = ''; setTimeout(() => q.oninput && q.oninput(), 0); } // One line that says what Search covers and what each source returned, with a link to sign in where a source needs it. const showSources = (r = {}) => { const el = $('#src'); if (!el) return; @@ -390,8 +393,9 @@ function viewSearch() { music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message }, }); const notes = [...(h.errors || [])].map(e => `

${esc(e)}

`).join(''); - res.innerHTML = card + sec('Tasks', taskHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes; - if (!taskHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; + const linkHtml = allowed('links') ? linkList().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')}${esc(l.title || l.url)}${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}`).join('') : ''; + res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes; + if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); }); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; }); @@ -642,48 +646,6 @@ const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const lab return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; }; const taskStore = []; // tasks on screen, so a tap can open the right one -// ---- Links: your own bookmarks, grouped, saved with your other settings (private to you, every device) -const safeUrl = s => { let t = String(s || '').trim(); if (!t) return ''; if (!/^[a-z][a-z0-9+.-]*:/i.test(t)) t = 'https://' + t; try { const u = new URL(t); return /^https?:$/.test(u.protocol) ? u.href : ''; } catch { return ''; } }; -const linkList = () => { const l = store.get('links', []); return Array.isArray(l) ? l.filter(x => x && safeUrl(x.url)) : []; }; -const LINK_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f9d9a', '#c2417a']; -const linkColor = l => LINK_COLORS[[...String(l.title || l.url)].reduce((n, c) => n + c.charCodeAt(0), 0) % LINK_COLORS.length]; -let linkQuery = ''; -function viewLinks() { - const all = linkList(), q = linkQuery.trim().toLowerCase(); - const shown = q ? all.filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(q)) : all; - const groups = [...new Set(shown.map(l => l.group || ''))].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b)); - $('#view').innerHTML = `

Links

-
- ${all.length ? '' : '

No links yet. Add the sites you use most; they are saved on your server and show up on every device.

'} - ${all.length && !shown.length ? '

Nothing matches.

' : ''} - ${groups.map(g => `${g ? `

${esc(g)}

` : (groups.length > 1 ? '

Other

' : '')}
${shown.filter(l => (l.group || '') === g).map(l => ` - `).join('')}
`).join('')}
`; - $('#lq').oninput = e => { linkQuery = e.target.value; const p = e.target.selectionStart; viewLinks(); const n = $('#lq'); n.focus(); n.setSelectionRange(p, p); }; - $('#ladd').onclick = () => linkSheet(); - document.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => linkSheet(all.find(l => l.id === b.dataset.edit))); -} -function linkSheet(link) { - const groups = [...new Set(linkList().map(l => l.group).filter(Boolean))]; - const el = openSheet(link ? 'Edit link' : 'Add a link', '', `
- - ${groups.map(g => ` -
${link ? '' : ''}
`); - if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet; - if (link) el.querySelector('#ld').onclick = () => confirmSheet('Delete link?', link.title, 'Delete', () => { store.set('links', linkList().filter(l => l.id !== link.id)); viewLinks(); }); - el.querySelector('#lf').onsubmit = e => { - e.preventDefault(); - const url = safeUrl(el.querySelector('#lu').value); - if (!url) { el.querySelector('#le').textContent = 'That is not a web address (it must be http or https).'; return; } - const next = { id: link ? link.id : 'l' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), title: el.querySelector('#lt').value.trim().slice(0, 60), url, group: el.querySelector('#lg').value.trim().slice(0, 40) }; - const rest = linkList(); - store.set('links', link ? rest.map(l => l.id === link.id ? next : l) : [...rest, next]); - closeSheet(); viewLinks(); - }; -} - async function viewTasks() { const v = $('#view'); v.innerHTML = '

Tasks

Loading…

'; diff --git a/public/index.html b/public/index.html index acf0145..4fcb28d 100644 --- a/public/index.html +++ b/public/index.html @@ -53,6 +53,8 @@
+ + diff --git a/public/links.js b/public/links.js new file mode 100644 index 0000000..8ea7852 --- /dev/null +++ b/public/links.js @@ -0,0 +1,43 @@ +// Links tab: your own bookmarks, grouped. Saved with the other per-person settings (see app.js: store). +// ---- Links: your own bookmarks, grouped, saved with your other settings (private to you, every device) +const safeUrl = s => { let t = String(s || '').trim(); if (!t) return ''; if (!/^[a-z][a-z0-9+.-]*:/i.test(t)) t = 'https://' + t; try { const u = new URL(t); return /^https?:$/.test(u.protocol) ? u.href : ''; } catch { return ''; } }; +const linkList = () => { const l = store.get('links', []); return Array.isArray(l) ? l.filter(x => x && safeUrl(x.url)) : []; }; +const LINK_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f9d9a', '#c2417a']; +const linkColor = l => LINK_COLORS[[...String(l.title || l.url)].reduce((n, c) => n + c.charCodeAt(0), 0) % LINK_COLORS.length]; +let linkQuery = ''; +function viewLinks() { + const all = linkList(), q = linkQuery.trim().toLowerCase(); + const shown = q ? all.filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(q)) : all; + const groups = [...new Set(shown.map(l => l.group || ''))].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b)); + $('#view').innerHTML = `

Links

+
+ ${all.length ? '' : '

No links yet. Add the sites you use most; they are saved on your server and show up on every device.

'} + ${all.length && !shown.length ? '

Nothing matches.

' : ''} + ${groups.map(g => `${g ? `

${esc(g)}

` : (groups.length > 1 ? '

Other

' : '')}
${shown.filter(l => (l.group || '') === g).map(l => ` + `).join('')}
`).join('')}
`; + $('#lq').oninput = e => { linkQuery = e.target.value; const p = e.target.selectionStart; viewLinks(); const n = $('#lq'); n.focus(); n.setSelectionRange(p, p); }; + $('#ladd').onclick = () => linkSheet(); + document.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => linkSheet(all.find(l => l.id === b.dataset.edit))); +} +function linkSheet(link) { + const groups = [...new Set(linkList().map(l => l.group).filter(Boolean))]; + const el = openSheet(link ? 'Edit link' : 'Add a link', '', `
+ + ${groups.map(g => ` +
${link ? '' : ''}
`); + if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet; + if (link) el.querySelector('#ld').onclick = () => confirmSheet('Delete link?', link.title, 'Delete', () => { store.set('links', linkList().filter(l => l.id !== link.id)); viewLinks(); }); + el.querySelector('#lf').onsubmit = e => { + e.preventDefault(); + const url = safeUrl(el.querySelector('#lu').value); + if (!url) { el.querySelector('#le').textContent = 'That is not a web address (it must be http or https).'; return; } + const next = { id: link ? link.id : 'l' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), title: el.querySelector('#lt').value.trim().slice(0, 60), url, group: el.querySelector('#lg').value.trim().slice(0, 40) }; + const rest = linkList(); + store.set('links', link ? rest.map(l => l.id === link.id ? next : l) : [...rest, next]); + closeSheet(); viewLinks(); + }; +} + diff --git a/public/palette.js b/public/palette.js new file mode 100644 index 0000000..8ab3c98 --- /dev/null +++ b/public/palette.js @@ -0,0 +1,113 @@ +// Command palette (Ctrl/Cmd-K) and quick capture. Uses the app's globals (allowed, TABS, icon, jfetch, openSheet, store, toast ...). + +/** "buy milk tomorrow" -> { title: 'buy milk', due: '2026-10-23' }. Understands today, tomorrow, a weekday name, "in N days" and YYYY-MM-DD at the end. */ +function parseQuickTask(text) { + let t = String(text || '').trim(), due = ''; + const d0 = new Date(); d0.setHours(12, 0, 0, 0); + const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + let m; + if ((m = /\s+(\d{4}-\d{2}-\d{2})$/.exec(t))) { due = m[1]; t = t.slice(0, m.index); } + else if ((m = /\s+in (\d{1,3}) days?$/i.exec(t))) { const d = new Date(d0); d.setDate(d.getDate() + +m[1]); due = fmt(d); t = t.slice(0, m.index); } + else if ((m = /\s+(today|tomorrow|tonight)$/i.exec(t))) { const d = new Date(d0); if (/tomorrow/i.test(m[1])) d.setDate(d.getDate() + 1); due = fmt(d); t = t.slice(0, m.index); } + else if ((m = /\s+(?:on |next )?(sun|mon|tue|wed|thu|fri|sat)[a-z]*$/i.exec(t))) { + const d = new Date(d0), want = days.indexOf(m[1].toLowerCase()); let add = (want - d.getDay() + 7) % 7; if (add === 0) add = 7; d.setDate(d.getDate() + add); due = fmt(d); t = t.slice(0, m.index); + } + return { title: t.trim() || String(text || '').trim(), due }; +} + +async function quickTask(text) { + const { title, due } = parseQuickTask(text); + if (!title) return; + await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: 'local:inbox', fields: { title, notes: '', due, done: false } }) }); + toast('Task added' + (due ? ' (due ' + due + ')' : '')); + if (location.hash.startsWith('#/tasks') && typeof viewTasks === 'function') viewTasks(); +} + +/** Small sheet to capture something fast from any screen. */ +function quickAdd() { + const can = t => allowed(t); + const el = openSheet('Quick add', '', `${can('tasks') ? `
+

Understands today, tomorrow, a weekday, "in 3 days" or a date (2026-12-25) at the end.

+
` : ''} +
${can('calendar') ? '' : ''}${can('links') ? '' : ''}
`); + const qt = el.querySelector('#qt'); if (qt) qt.focus(); + if (el.querySelector('#qf')) el.querySelector('#qf').onsubmit = async e => { e.preventDefault(); try { await quickTask(qt.value); closeSheet(); } catch (err) { el.querySelector('#qerr').textContent = err.message; } }; + if (el.querySelector('#qe')) el.querySelector('#qe').onclick = () => { closeSheet(); newEvent(ymd(new Date())); }; + if (el.querySelector('#ql')) el.querySelector('#ql').onclick = () => { closeSheet(); linkSheet(); }; +} + +// ---- Command palette +let palEl = null; +function openPalette() { + if (palEl) return closePalette(); + const lastFocus = document.activeElement; + palEl = document.createElement('div'); palEl.id = 'pal'; + palEl.innerHTML = `
`; + document.body.appendChild(palEl); + const input = palEl.querySelector('#palq'), list = palEl.querySelector('#pall'); + let items = [], sel = 0, seq = 0, timer, live = []; + const go = h => () => { location.hash = h; }; + const base = () => { + const out = TABS.filter(t => allowed(t[0])).map(([id, label]) => ({ label: 'Go to ' + label, hint: 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label })); + if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' }); + if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' }); + if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' }); + out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' }); + out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' }); + return out; + }; + const build = () => { + const q = input.value.trim(), ql = q.toLowerCase(); + let all = base(); + if (typeof linkList === 'function' && allowed('links')) all = all.concat(linkList().map(l => ({ label: l.title || l.url, hint: 'Link · ' + new URL(l.url).host, ic: 'link', run: () => window.open(l.url, '_blank', 'noopener'), kw: (l.group || '') + ' ' + l.url }))); + let res = ql ? all.filter(i => (i.label + ' ' + i.kw).toLowerCase().includes(ql)) : all.slice(0, 12); + if (ql) { + res = res.sort((a, b) => (b.label.toLowerCase().startsWith(ql)) - (a.label.toLowerCase().startsWith(ql))); + res = res.concat(live); + if (allowed('tasks')) res.push({ label: 'Add task: ' + q, hint: 'Create', ic: 'plus', run: () => quickTask(q).catch(e => toast(e.message)) }); + if (allowed('search')) res.push({ label: 'Search everything for “' + q + '”', hint: 'Search', ic: 'search', run: () => { window.pendingSearch = q; if (location.hash === '#/search') route(); else location.hash = '#/search'; } }); + } + items = res; sel = Math.min(sel, Math.max(0, items.length - 1)); + list.innerHTML = items.length ? items.map((i, n) => `
${icon(i.ic)}${esc(i.label)}${esc(i.hint)}
`).join('') : '

Nothing matches.

'; + input.setAttribute('aria-activedescendant', items.length ? 'po' + sel : ''); + const on = list.querySelector('.on'); if (on) on.scrollIntoView({ block: 'nearest' }); + }; + const fetchLive = q => { // tasks and events from the server, shown under the commands + clearTimeout(timer); live = []; + if (q.length < 2 || !(allowed('tasks') || allowed('calendar'))) return; + const mine = ++seq; + timer = setTimeout(async () => { + try { + const r = await jfetch('/api/search?q=' + encodeURIComponent(q)); + if (mine !== seq || !palEl) return; + live = (r.tasks || []).slice(0, 4).map(t => ({ label: t.title, hint: 'Task' + (t.due ? ' · due ' + t.due : '') + (t.done ? ' · done' : ''), ic: 'check', run: go('#/tasks') })) + .concat((r.events || []).slice(0, 4).map(e => ({ label: e.title, hint: 'Event · ' + (e.allDay ? e.start : new Date(e.start).toLocaleDateString([], { month: 'short', day: 'numeric' })), ic: 'cal', run: go('#/calendar') }))); + build(); + } catch {} + }, 250); + }; + const run = n => { const it = items[n]; if (!it) return; closePalette(); setTimeout(it.run, 0); }; + input.oninput = () => { sel = 0; fetchLive(input.value.trim()); build(); }; + input.onkeydown = e => { + if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % Math.max(1, items.length); build(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % Math.max(1, items.length); build(); } + else if (e.key === 'Enter') { e.preventDefault(); run(sel); } + else if (e.key === 'Escape') { e.preventDefault(); closePalette(); } + else if (e.key === 'Tab') e.preventDefault(); // keep focus in the box + }; + list.onclick = e => { const r = e.target.closest('.palrow'); if (r) run(+r.dataset.n); }; + palEl.querySelector('.scrim').onclick = closePalette; + palEl._restore = lastFocus; + build(); input.focus(); + requestAnimationFrame(() => palEl && palEl.classList.add('on')); +} +function closePalette() { if (!palEl) return; const r = palEl._restore; palEl.remove(); palEl = null; try { r && r.focus && r.focus(); } catch {} } +addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { + if (document.body.classList.contains('editing') || !document.getElementById('nav')) return; // not on the sign-in page or inside the document editor + e.preventDefault(); openPalette(); + } +}); diff --git a/public/style.css b/public/style.css index b600037..bc43da8 100644 --- a/public/style.css +++ b/public/style.css @@ -482,3 +482,16 @@ body.lyropen { overflow: hidden; } .npc-l { min-height: 38px; font-size: 14px; } @media (max-width: 560px) { .lyrtop .lyrmode { display: none; } .lyrfoot #lyrs { padding: 0 10px; font-size: 13px; } .npc-l { display: inline-flex; } .npc-ctl { gap: 0; } .npc-q { display: none; } } .lyrnudge small, .lyrfoot #lyrs { white-space: nowrap; } + +/* Command palette */ +#pal { position: fixed; inset: 0; z-index: 60; display: flex; justify-content: center; align-items: flex-start; padding: calc(var(--sat) + 12vh) 12px 12px; } +#pal .scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, .45); } +.palbox { position: relative; width: min(560px, 100%); background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, .35); overflow: hidden; } +.palbox input { width: 100%; border: 0; border-bottom: 1px solid var(--line); background: transparent; padding: 16px 18px; font-size: 17px; outline: none; } +#pall { max-height: min(50vh, 420px); overflow-y: auto; padding: 6px; } +.palrow { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; } +.palrow .grow { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palrow small { color: var(--muted); flex: none; } +.palrow.on { background: var(--panel2); } +.palrow svg { width: 20px; height: 20px; color: var(--muted); } +@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } } diff --git a/server.js b/server.js index ab7d39e..1941db2 100644 --- a/server.js +++ b/server.js @@ -99,7 +99,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +113,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';