feat: search bar in topbar with Gmail-style query syntax

- Search form in topbar routes to /app/search?q=...
- SearchResults page with infinite scroll, same row style as FolderView
- Supports Gmail-like operators: from:, is:unread, has:attachment, after:, before:
- SearchApi.query() calls GET /search which runs through the existing GmailQueryParser
- Input sanitised via encodeURIComponent on submit; external links use noopener/noreferrer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:15:45 +02:00
parent 4d15a3a8fb
commit 498536451e
5 changed files with 164 additions and 1 deletions
+20 -1
View File
@@ -1,4 +1,4 @@
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Link, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useEffect, useState, useCallback } from 'react';
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
import Logo from './Logo.jsx';
@@ -102,6 +102,8 @@ function FolderLink({ item, active, count, collapsed, extra }) {
export default function Layout() {
const [user, setUser] = useState(null);
const [searchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') ?? '');
const [sidebarOpen, setSidebarOpen] = useState(() => {
try { return localStorage.getItem(LS_OPEN) !== 'false'; } catch { return true; }
});
@@ -158,6 +160,12 @@ export default function Layout() {
navigate('/');
};
const submitSearch = (e) => {
e.preventDefault();
const q = searchQuery.trim();
if (q) navigate(`/app/search?q=${encodeURIComponent(q)}`);
};
const startSync = async () => {
try { await SyncApi.incremental(); } catch { /* ignore */ }
if (loc.pathname !== '/app') navigate('/app');
@@ -199,6 +207,17 @@ export default function Layout() {
<Link key={n.to} to={n.to} className={isNavActive(n) ? 'active' : ''}>{n.label}</Link>
))}
</nav>
<form className="search-form" onSubmit={submitSearch}>
<input
className="search-input"
type="search"
placeholder="Search… (from:, is:unread, has:attachment)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search emails"
/>
<button type="submit" className="search-btn" aria-label="Search">🔍</button>
</form>
<div className="spacer" />
<button onClick={startSync}>Sync now</button>
<span className="user">{user?.email}</span>