From 29f92dbf9e1fcd4e522cfa5885fbcfdad2b8486a Mon Sep 17 00:00:00 2001 From: th Date: Wed, 15 Apr 2026 22:02:51 +0200 Subject: [PATCH] search: right-click a hit to move it to a bucket Drag was the only way to move a search hit into a bucket, which meant opening the main board to find a drop target first. Right-click on a search-hit now opens a small bucket picker anchored at the cursor: clicking a bucket PATCHes the card's bucket_id and re-runs the current search so the row updates in place. Includes an "(unsorted)" entry that clears the bucket. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- electron/package.json | 2 +- public/js/modules/search.js | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/config.php b/config.php index 537e89e..78d823f 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ ${htags.slice(0, 3).map(t => `#${esc(t)}`).join('')}` : ''}`; } +function openBucketPicker(cardId, x, y) { + document.querySelector('.search-bucket-picker')?.remove(); + const picker = document.createElement('div'); + picker.className = 'search-bucket-picker'; + // Clamp to viewport so a click near the bottom-right doesn't flow + // off-screen — rendered invisibly first to measure, then placed. + picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`; + + const list = document.createElement('div'); + list.style.cssText = 'overflow-y:auto;flex:1;'; + + const addRow = (label, color, onClick) => { + const item = document.createElement('button'); + item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;'; + item.innerHTML = `${esc(label)}`; + item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; }); + item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; }); + item.addEventListener('click', onClick); + list.appendChild(item); + }; + + addRow('(unsorted)', 'transparent', async () => { + picker.remove(); + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } }); + await window._reloadAll(); + search(input.value); + }); + state.buckets.forEach(b => { + addRow(b.name, b.color || '#64748b', async () => { + picker.remove(); + await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } }); + await window._reloadAll(); + search(input.value); + }); + }); + + picker.appendChild(list); + document.body.appendChild(picker); + + // Place, then flip if we'd overflow the viewport. + const r = picker.getBoundingClientRect(); + let left = x, top = y; + if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8); + if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8); + picker.style.left = left + 'px'; + picker.style.top = top + 'px'; + picker.style.visibility = ''; + + const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } }; + const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } }; + const cleanup = () => { + document.removeEventListener('mousedown', onDown, true); + document.removeEventListener('keydown', onEsc, true); + }; + setTimeout(() => { + document.addEventListener('mousedown', onDown, true); + document.addEventListener('keydown', onEsc, true); + }, 0); +} + function bindHits() { results.querySelectorAll('.search-hit').forEach(el => { el.addEventListener('click', () => { @@ -234,6 +294,16 @@ function bindHits() { else { active = i; updateActive(); } }); el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); }); + el.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const id = el.dataset.cardId; + if (!id) return; + // Highlight the row so the user sees which card the picker + // will move — mirrors the active-on-click behaviour. + const i = parseInt(el.dataset.idx); + if (!isNaN(i)) { active = i; updateActive(); } + openBucketPicker(id, e.clientX, e.clientY); + }); // Drag: only non-group, non-child cards if (el.draggable) {