feat: saved searches

Lets users name and pin a search query from the SearchResults page;
saved searches persist to localStorage and show as quick links in a new
sidebar section, similar to Favorites. Clicking re-runs the query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 22:00:12 +02:00
parent c7ead8caae
commit 9bd3799fbc
3 changed files with 79 additions and 3 deletions
+34 -2
View File
@@ -4,6 +4,7 @@ import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
import Logo from './Logo.jsx';
import DevBanner from './DevBanner.jsx';
import SyncStatus from './SyncStatus.jsx';
import useSavedSearches from '../hooks/useSavedSearches.js';
// ── Folder definitions ────────────────────────────────────────────────────────
@@ -78,8 +79,8 @@ const loadFavs = () => {
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
const loadSections = () => {
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true }; }
catch { return { fav: true, mailbox: true, smart: true }; }
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true, saved: true }; }
catch { return { fav: true, mailbox: true, smart: true, saved: true }; }
};
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
@@ -126,6 +127,7 @@ export default function Layout() {
const [sections, setSections] = useState(loadSections);
const [favSlugs, setFavSlugs] = useState(loadFavs);
const [counts, setCounts] = useState(null);
const { searches: savedSearches, remove: removeSavedSearch } = useSavedSearches();
const loc = useLocation();
const navigate = useNavigate();
@@ -291,6 +293,36 @@ export default function Layout() {
</ul>
)}
{/* ── Saved Searches ── */}
{savedSearches.length > 0 && (
<>
<SectionHead label="Saved Searches" open={sections.saved} onToggle={() => toggleSection('saved')} collapsed={!sidebarOpen} />
{sections.saved && (
<ul className="folder-list">
{savedSearches.map((s) => (
<li key={s.id} className="saved-search-row">
<Link
to={`/app/search?q=${encodeURIComponent(s.query)}`}
className={`folder-item${loc.pathname === '/app/search' && searchParams.get('q') === s.query ? ' folder-item--active' : ''}`}
title={s.query}
>
<span className="folder-icon">🔎</span>
{sidebarOpen && <span className="folder-label">{s.label}</span>}
</Link>
{sidebarOpen && (
<button
className="fav-pin fav-pin--remove"
title="Remove saved search"
onClick={(e) => { e.preventDefault(); removeSavedSearch(s.id); }}
></button>
)}
</li>
))}
</ul>
)}
</>
)}
{/* ── Mailbox ── */}
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
{sections.mailbox && (
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useState } from 'react';
const LS_KEY = 'ii:saved-searches';
const load = () => {
try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? []; }
catch { return []; }
};
const save = (v) => localStorage.setItem(LS_KEY, JSON.stringify(v));
export default function useSavedSearches() {
const [searches, setSearches] = useState(load);
const add = useCallback((label, query) => {
setSearches((prev) => {
const next = [...prev.filter((s) => s.query !== query), { id: crypto.randomUUID(), label, query }];
save(next);
return next;
});
}, []);
const remove = useCallback((id) => {
setSearches((prev) => {
const next = prev.filter((s) => s.id !== id);
save(next);
return next;
});
}, []);
return { searches, add, remove };
}
+14 -1
View File
@@ -5,6 +5,7 @@ import EmailRow from '../components/EmailRow.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
import useSavedSearches from '../hooks/useSavedSearches.js';
const PAGE_SIZE = 50;
@@ -21,6 +22,13 @@ export default function SearchResults() {
const [error, setError] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
const isSaved = savedSearches.some((s) => s.query === q);
const handleSave = () => {
const label = window.prompt('Name this saved search:', q);
if (label && label.trim()) addSavedSearch(label.trim(), q);
};
useEffect(() => {
setEmails([]);
@@ -63,11 +71,16 @@ export default function SearchResults() {
return (
<div className="folder-view">
<div className="folder-view-head">
<div className="folder-view-head saved-search-row">
<h2 className="folder-view-title">🔍 "{q}"</h2>
{totalCount !== null && (
<span className="folder-view-count">{totalCount.toLocaleString()} result{totalCount !== 1 ? 's' : ''}</span>
)}
{q.trim() && (
<button className="saved-search-save-btn" onClick={handleSave} disabled={isSaved}>
{isSaved ? '★ Saved' : '☆ Save search'}
</button>
)}
</div>
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}