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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:45:33 -04:00
1 parent 1f8c9493b7
commit df8c6ce586
6 files changed
+179 -46

No files matched your search

+6 -44
View File
@@ -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>';