Command palette (Ctrl/Cmd-K), quick add with natural dates, links in search; links.js split out of app.js
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f8c9493b7
commit
df8c6ce586
6 files changed
+179
-46
No files matched your search
+6
-44
@@ -246,6 +246,7 @@ function viewHome() {
|
||||
const t = typeof cur === 'function' ? cur() : null;
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
|
||||
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
||||
<div class="bar" style="margin-top:8px"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div>
|
||||
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
||||
<div id="tcard"></div>
|
||||
<div id="agenda"></div>
|
||||
@@ -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() {
|
||||
<div id="res"></div></div>`;
|
||||
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 => `<p class="sub">${esc(e)}</p>`).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 = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
||||
const linkHtml = allowed('links') ? linkList().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).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 = '<p class="empty">No results. The line above shows what was searched.</p>' + 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 = `<div class="wrap"><h1>Links</h1>
|
||||
<div class="bar"><input type="search" id="lq" placeholder="Filter links" value="${esc(linkQuery)}" aria-label="Filter links" style="flex:1"><button class="btn" id="ladd">${icon('plus')}Add</button></div>
|
||||
${all.length ? '' : '<p class="empty">No links yet. Add the sites you use most; they are saved on your server and show up on every device.</p>'}
|
||||
${all.length && !shown.length ? '<p class="empty">Nothing matches.</p>' : ''}
|
||||
${groups.map(g => `${g ? `<h2>${esc(g)}</h2>` : (groups.length > 1 ? '<h2>Other</h2>' : '')}<div class="list">${shown.filter(l => (l.group || '') === g).map(l => `
|
||||
<div class="row"><a class="grow" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;min-width:0">
|
||||
<span class="ic" style="background:${linkColor(l)};color:#fff;font-weight:700">${esc([...(l.title || l.url)][0].toUpperCase())}</span>
|
||||
<span class="grow" style="min-width:0"><b>${esc(l.title || l.url)}</b><small style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(new URL(safeUrl(l.url)).host)}</small></span></a>
|
||||
<button class="btn ghost" data-edit="${esc(l.id)}" aria-label="Edit ${esc(l.title)}" style="min-height:36px">Edit</button></div>`).join('')}</div>`).join('')}</div>`;
|
||||
$('#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', '', `<form id="lf"><label for="lt">Name</label><input type="text" id="lt" required autocomplete="off" value="${esc(link ? link.title : '')}" placeholder="Router">
|
||||
<label for="lu">Address</label><input type="text" id="lu" required autocomplete="off" autocapitalize="off" inputmode="url" value="${esc(link ? link.url : '')}" placeholder="https://192.168.1.1">
|
||||
<label for="lg">Group (optional)</label><input type="text" id="lg" list="lgl" autocomplete="off" value="${esc(link ? link.group || '' : '')}" placeholder="Home network"><datalist id="lgl">${groups.map(g => `<option value="${esc(g)}">`).join('')}</datalist>
|
||||
<div class="bar">${link ? '<button class="btn danger" type="button" id="ld" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="le"></div></form>`);
|
||||
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 = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="/webauthn.js"></script>
|
||||
<script src="/links.js"></script>
|
||||
<script src="/palette.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `<div class="wrap"><h1>Links</h1>
|
||||
<div class="bar"><input type="search" id="lq" placeholder="Filter links" value="${esc(linkQuery)}" aria-label="Filter links" style="flex:1"><button class="btn" id="ladd">${icon('plus')}Add</button></div>
|
||||
${all.length ? '' : '<p class="empty">No links yet. Add the sites you use most; they are saved on your server and show up on every device.</p>'}
|
||||
${all.length && !shown.length ? '<p class="empty">Nothing matches.</p>' : ''}
|
||||
${groups.map(g => `${g ? `<h2>${esc(g)}</h2>` : (groups.length > 1 ? '<h2>Other</h2>' : '')}<div class="list">${shown.filter(l => (l.group || '') === g).map(l => `
|
||||
<div class="row"><a class="grow" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;min-width:0">
|
||||
<span class="ic" style="background:${linkColor(l)};color:#fff;font-weight:700">${esc([...(l.title || l.url)][0].toUpperCase())}</span>
|
||||
<span class="grow" style="min-width:0"><b>${esc(l.title || l.url)}</b><small style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(new URL(safeUrl(l.url)).host)}</small></span></a>
|
||||
<button class="btn ghost" data-edit="${esc(l.id)}" aria-label="Edit ${esc(l.title)}" style="min-height:36px">Edit</button></div>`).join('')}</div>`).join('')}</div>`;
|
||||
$('#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', '', `<form id="lf"><label for="lt">Name</label><input type="text" id="lt" required autocomplete="off" value="${esc(link ? link.title : '')}" placeholder="Router">
|
||||
<label for="lu">Address</label><input type="text" id="lu" required autocomplete="off" autocapitalize="off" inputmode="url" value="${esc(link ? link.url : '')}" placeholder="https://192.168.1.1">
|
||||
<label for="lg">Group (optional)</label><input type="text" id="lg" list="lgl" autocomplete="off" value="${esc(link ? link.group || '' : '')}" placeholder="Home network"><datalist id="lgl">${groups.map(g => `<option value="${esc(g)}">`).join('')}</datalist>
|
||||
<div class="bar">${link ? '<button class="btn danger" type="button" id="ld" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="le"></div></form>`);
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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') ? `<form id="qf"><input type="text" id="qt" placeholder="Task, e.g. Call mom tomorrow" autocomplete="off" aria-label="New task" style="margin-top:6px">
|
||||
<p class="sub" style="margin:6px 0 0">Understands today, tomorrow, a weekday, "in 3 days" or a date (2026-12-25) at the end.</p>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Add task</button></div></form>` : ''}
|
||||
<div class="bar">${can('calendar') ? '<button class="btn ghost" id="qe" style="flex:1;justify-content:center">New event</button>' : ''}${can('links') ? '<button class="btn ghost" id="ql" style="flex:1;justify-content:center">New link</button>' : ''}</div><div class="err" id="qerr"></div>`);
|
||||
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 = `<div class="scrim"></div><div class="palbox" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input type="text" id="palq" role="combobox" aria-expanded="true" aria-controls="pall" aria-autocomplete="list" placeholder="Type a command, a page, a task or a link" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<div id="pall" role="listbox" aria-label="Results"></div></div>`;
|
||||
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) => `<div class="palrow${n === sel ? ' on' : ''}" role="option" id="po${n}" aria-selected="${n === sel}" data-n="${n}">${icon(i.ic)}<span class="grow">${esc(i.label)}</span><small>${esc(i.hint)}</small></div>`).join('') : '<p class="empty">Nothing matches.</p>';
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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; } }
|
||||
Reference in new issue
Block a user