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:
@@ -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 && (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user