diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index cef7c71..4f48e19 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -65,6 +65,8 @@ export const LayoutApi = {
export const SearchApi = {
folder: (slug, page = 1, pageSize = 50) =>
api.post('/search', folderToRequest(slug, page, pageSize)).then((r) => r.data),
+ query: (q, page = 1, pageSize = 50) =>
+ api.get('/search', { params: { q, page, pageSize } }).then((r) => r.data),
};
function folderToRequest(slug, page, pageSize) {
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index 6d2707f..1fbe3ad 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -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() {
{n.label}
))}
+
{user?.email}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index d5bab77..3622d41 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -7,6 +7,7 @@ import Senders from './pages/Senders.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import FolderView from './pages/FolderView.jsx';
+import SearchResults from './pages/SearchResults.jsx';
import Layout from './components/Layout.jsx';
import './styles.css';
@@ -24,6 +25,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx
new file mode 100644
index 0000000..7ba9e3b
--- /dev/null
+++ b/frontend/src/pages/SearchResults.jsx
@@ -0,0 +1,126 @@
+import { useEffect, useState, useCallback, useRef } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { SearchApi } from '../api/client.js';
+
+const PAGE_SIZE = 50;
+
+const fmtDate = (iso) => {
+ const d = new Date(iso);
+ const now = new Date();
+ const diffDays = (now - d) / 86400000;
+ if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
+};
+
+const fmtSize = (b) => {
+ if (b < 1024) return `${b} B`;
+ if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
+ return `${(b / 1048576).toFixed(1)} MB`;
+};
+
+export default function SearchResults() {
+ const [searchParams] = useSearchParams();
+ const q = searchParams.get('q') ?? '';
+
+ const [emails, setEmails] = useState([]);
+ const [totalCount, setTotalCount] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setEmails([]);
+ setTotalCount(null);
+ setPage(1);
+ setHasMore(true);
+ setError(null);
+ }, [q]);
+
+ const fetchPage = useCallback((p) => {
+ if (!q.trim()) return;
+ setLoading(true);
+ SearchApi.query(q, p, PAGE_SIZE)
+ .then((r) => {
+ setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
+ setTotalCount(r.totalCount);
+ setHasMore(p < r.totalPages);
+ setPage(p);
+ })
+ .catch(() => setError('Search failed. Please try again.'))
+ .finally(() => setLoading(false));
+ }, [q]);
+
+ useEffect(() => { fetchPage(1); }, [fetchPage]);
+
+ const sentinelRef = useRef(null);
+ useEffect(() => {
+ const el = sentinelRef.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1);
+ },
+ { rootMargin: '200px' }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [hasMore, loading, page, fetchPage]);
+
+ return (
+
+
+
π "{q}"
+ {totalCount !== null && (
+ {totalCount.toLocaleString()} result{totalCount !== 1 ? 's' : ''}
+ )}
+
+
+ {!q.trim() &&
Enter a search query above.
}
+ {error &&
{error}
}
+ {!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
+
No results for "{q}".
+ )}
+
+ {emails.length > 0 && (
+
+
+ {emails.map((e) => (
+ window.open(
+ `https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
+ '_blank',
+ 'noopener,noreferrer'
+ )}
+ title="Open in Gmail"
+ >
+ | {e.isUnread && } |
+
+ {e.senderDisplayName || e.senderAddress}
+ |
+
+ {e.subject || '(no subject)'}
+ {e.snippet && β {e.snippet}}
+ |
+
+ {e.hasAttachments && π}
+ {e.sizeEstimateBytes > 1048576 && {fmtSize(e.sizeEstimateBytes)}}
+ |
+ {fmtDate(e.sentAtUtc)} |
+
+ ))}
+
+
+ )}
+
+
+ {loading &&
Loadingβ¦
}
+ {!hasMore && emails.length > 0 && (
+
β {emails.length.toLocaleString()} results β
+ )}
+
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 1259ef7..38a04c3 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -17,6 +17,20 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
.topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.spacer { flex: 1; }
+
+/* ββ Search bar ββ */
+.search-form { display: flex; align-items: center; gap: 0; flex: 1; max-width: 420px; }
+.search-input {
+ flex: 1; background: var(--panel-2); border: 1px solid #2c3550; border-right: none;
+ color: var(--text); border-radius: 6px 0 0 6px; padding: 7px 10px; font-size: 13px;
+ min-width: 0;
+}
+.search-input:focus { outline: none; border-color: var(--accent); }
+.search-btn {
+ background: var(--panel-2); border: 1px solid #2c3550; border-left: none;
+ color: var(--muted); border-radius: 0 6px 6px 0; padding: 7px 10px; cursor: pointer; font-size: 13px;
+}
+.search-btn:hover { color: var(--text); border-color: var(--accent); }
.user { color: var(--muted); font-size: 13px; }
/* ββ App body (sidebar + main) ββ */