search: saved searches via bookmark button

Small bookmark icon in the search header bar opens a popup listing
localStorage-persisted search strings. Clicking an entry fills the
input and runs the search; the x button deletes it; "save current
search" at the bottom saves the active input (no dupes). List
persists across sessions, no server round-trip.

Tests: 6 cases covering button/popup toggle, save + recall, delete,
save-current-button, and duplicate rejection. All snapshot/restore
localStorage in try/finally so they don't leak into user data or
cross-test state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 09:09:18 +02:00
1 parent be28080439
commit 883640e20c
5 files changed
+218 -2

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.21'); define('APP_VERSION', '1.9.22');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.21", "version": "1.9.22",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+9
View File
@@ -2051,6 +2051,15 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-t-accent text-base"></i> <i class="ph ph-magnifying-glass text-t-accent text-base"></i>
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false"> <input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select> <select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
<div class="relative">
<button id="search-saved-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="saved searches">
<i class="ph ph-bookmark-simple text-sm"></i>
</button>
<div id="search-saved-popup" class="hidden absolute right-0 top-full mt-1 min-w-[220px] max-h-[280px] bg-t-surface border border-t-border shadow-2xl z-50 flex flex-col" style="border-radius:var(--ui-radius-modal,4px)">
<div id="search-saved-list" class="overflow-y-auto flex-1"></div>
<button id="search-saved-add" class="w-full text-left px-3 py-2 text-[10px] font-pixel text-t-accent border-t border-t-border hover:bg-t-raised transition-colors"><i class="ph ph-plus text-xs"></i> save current search</button>
</div>
</div>
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open."> <button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open.">
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span> <i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
</button> </button>
+85
View File
@@ -403,3 +403,88 @@ replaceAllBtn?.addEventListener('click', async () => {
search(input.value); search(input.value);
setTimeout(() => { replaceStatus.style.color = ''; }, 3000); setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
}); });
// ── Saved searches ──────────────────────────────────────────────
// localStorage-persisted list of search strings. The bookmark button
// toggles a popup that lets the user pick one (fills input + runs),
// save the current input value, or delete saved entries.
const SAVED_KEY = 'hyprcrd-saved-searches';
const savedBtn = $('search-saved-btn');
const savedPopup = $('search-saved-popup');
const savedList = $('search-saved-list');
const savedAdd = $('search-saved-add');
function loadSaved() {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '[]'); } catch { return []; }
}
function storeSaved(arr) {
localStorage.setItem(SAVED_KEY, JSON.stringify(arr));
}
function renderSavedList() {
if (!savedList) return;
const items = loadSaved();
if (!items.length) {
savedList.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel text-center">no saved searches</div>';
return;
}
savedList.innerHTML = items.map((q, i) => `
<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-t-raised cursor-pointer transition-colors search-saved-row" data-idx="${i}">
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<span class="flex-1 text-xs text-t-muted truncate">${esc(q)}</span>
<button class="search-saved-del text-t-dim hover:text-t-danger text-xs px-1" data-idx="${i}" title="remove"><i class="ph ph-x"></i></button>
</div>
`).join('');
savedList.querySelectorAll('.search-saved-row').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.closest('.search-saved-del')) return;
const q = items[parseInt(row.dataset.idx)];
if (q !== undefined) {
input.value = q;
search(q);
closeSavedPopup();
}
});
});
savedList.querySelectorAll('.search-saved-del').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const arr = loadSaved();
arr.splice(idx, 1);
storeSaved(arr);
renderSavedList();
});
});
}
function openSavedPopup() {
renderSavedList();
savedPopup?.classList.remove('hidden');
const close = (e) => {
if (savedPopup?.contains(e.target) || e.target === savedBtn) return;
closeSavedPopup();
document.removeEventListener('mousedown', close, true);
};
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
}
function closeSavedPopup() {
savedPopup?.classList.add('hidden');
}
savedBtn?.addEventListener('click', (e) => {
e.stopPropagation();
if (savedPopup?.classList.contains('hidden')) openSavedPopup();
else closeSavedPopup();
});
savedAdd?.addEventListener('click', () => {
const q = input.value.trim();
if (!q) return;
const arr = loadSaved();
if (arr.includes(q)) return; // no dupes
arr.unshift(q);
storeSaved(arr);
renderSavedList();
});
+122
View File
@@ -44,3 +44,125 @@ describe('Search', () => {
expect($('#search')).toBeHidden(); expect($('#search')).toBeHidden();
}); });
}); });
describe('Search: saved searches', () => {
const SAVED_KEY = 'hyprcrd-saved-searches';
afterEach(async () => {
// Close overlays and restore localStorage to avoid cross-test pollution.
document.querySelector('#search-saved-popup')?.classList.add('hidden');
await closeAll();
});
it('bookmark button and popup exist', () => {
expect($('#search-saved-btn')).toExist();
expect($('#search-saved-popup')).toExist();
expect($('#search-saved-popup').classList.contains('hidden')).toBe(true);
});
it('clicking bookmark opens the popup', async () => {
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(40);
expect($('#search-saved-popup').classList.contains('hidden')).toBe(false);
});
it('saves and recalls a search', async () => {
// Seed localStorage directly to avoid UI coupling for the save path.
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_search_probe__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe, ...before]));
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(60);
const popup = $('#search-saved-popup');
const rows = popup.querySelectorAll('.search-saved-row');
expect(rows.length).toBeGreaterThan(0);
// First row should be our probe.
expect(rows[0].textContent).toContain(probe);
// Click the row → fills input.
click(rows[0]);
await sleep(40);
expect($('#search-input').value).toBe(probe);
} finally {
// Restore original saved searches.
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('deletes a saved search', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_del__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe]));
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(60);
const delBtn = $('#search-saved-popup').querySelector('.search-saved-del');
expect(delBtn).toExist();
click(delBtn);
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
expect(after.includes(probe)).toBe(false);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('save-current button adds input value', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_add_' + Date.now() + '__';
try {
click('#search-btn');
await sleep(100);
const inp = $('#search-input');
inp.value = probe;
inp.dispatchEvent(new Event('input', { bubbles: true }));
click('#search-saved-btn');
await sleep(40);
click('#search-saved-add');
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
expect(after.includes(probe)).toBe(true);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('does not add duplicates', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_dupe__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe]));
click('#search-btn');
await sleep(100);
const inp = $('#search-input');
inp.value = probe;
inp.dispatchEvent(new Event('input', { bubbles: true }));
click('#search-saved-btn');
await sleep(40);
click('#search-saved-add');
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const count = after.filter(q => q === probe).length;
expect(count).toBe(1);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
});