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:
1 parent
be28080439
commit
883640e20c
5 files changed
+218
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.21');
|
||||
define('APP_VERSION', '1.9.22');
|
||||
|
||||
// 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.21",
|
||||
"version": "1.9.22",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -2051,6 +2051,15 @@ if (str_starts_with($route, '/api/')) {
|
||||
<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">
|
||||
<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.">
|
||||
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
||||
</button>
|
||||
|
||||
@@ -403,3 +403,88 @@ replaceAllBtn?.addEventListener('click', async () => {
|
||||
search(input.value);
|
||||
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();
|
||||
});
|
||||
@@ -44,3 +44,125 @@ describe('Search', () => {
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user