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 Logo from './Logo.jsx';
|
||||||
import DevBanner from './DevBanner.jsx';
|
import DevBanner from './DevBanner.jsx';
|
||||||
import SyncStatus from './SyncStatus.jsx';
|
import SyncStatus from './SyncStatus.jsx';
|
||||||
|
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||||
|
|
||||||
// ── Folder definitions ────────────────────────────────────────────────────────
|
// ── Folder definitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -78,8 +79,8 @@ const loadFavs = () => {
|
|||||||
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
|
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
|
||||||
|
|
||||||
const loadSections = () => {
|
const loadSections = () => {
|
||||||
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { 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 }; }
|
catch { return { fav: true, mailbox: true, smart: true, saved: true }; }
|
||||||
};
|
};
|
||||||
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
|
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export default function Layout() {
|
|||||||
const [sections, setSections] = useState(loadSections);
|
const [sections, setSections] = useState(loadSections);
|
||||||
const [favSlugs, setFavSlugs] = useState(loadFavs);
|
const [favSlugs, setFavSlugs] = useState(loadFavs);
|
||||||
const [counts, setCounts] = useState(null);
|
const [counts, setCounts] = useState(null);
|
||||||
|
const { searches: savedSearches, remove: removeSavedSearch } = useSavedSearches();
|
||||||
|
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -291,6 +293,36 @@ export default function Layout() {
|
|||||||
</ul>
|
</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 ── */}
|
{/* ── Mailbox ── */}
|
||||||
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
|
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
|
||||||
{sections.mailbox && (
|
{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 BulkToolbar from '../components/BulkToolbar.jsx';
|
||||||
import useSelection from '../hooks/useSelection.js';
|
import useSelection from '../hooks/useSelection.js';
|
||||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||||
|
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -21,6 +22,13 @@ export default function SearchResults() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
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(() => {
|
useEffect(() => {
|
||||||
setEmails([]);
|
setEmails([]);
|
||||||
@@ -63,11 +71,16 @@ export default function SearchResults() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="folder-view">
|
<div className="folder-view">
|
||||||
<div className="folder-view-head">
|
<div className="folder-view-head saved-search-row">
|
||||||
<h2 className="folder-view-title">🔍 "{q}"</h2>
|
<h2 className="folder-view-title">🔍 "{q}"</h2>
|
||||||
{totalCount !== null && (
|
{totalCount !== null && (
|
||||||
<span className="folder-view-count">{totalCount.toLocaleString()} result{totalCount !== 1 ? 's' : ''}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
|
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
|
||||||
|
|||||||
Reference in New Issue
Block a user