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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d5ebbab6a5
commit
29f92dbf9e
3 files changed
+72
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.15');
|
||||
define('APP_VERSION', '1.9.16');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.9.15",
|
||||
"version": "1.9.16",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -226,6 +226,66 @@ function hitHtml(card, idx, words) {
|
||||
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
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 = `<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(label)}</span>`;
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user