diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 14ab996..8329a08 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -51,6 +51,21 @@ export const CleanupApi = { execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data) }; +// Bulk actions over an explicit set of email IDs (selection-driven, always confirmed — +// the user already opted in by selecting rows and clicking the action). +// Numeric values must match CleanupActionType in Domain/Enums/Enums.cs (no string +// enum converter is configured on the API, so plain numbers are required here). +const CLEANUP_ACTION = { Archive: 0, Trash: 1, MarkRead: 5, MarkUnread: 6, Star: 7, Unstar: 8 }; + +export const BulkApi = { + markRead: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkRead, emailIds: ids, confirmed: true }), + markUnread: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkUnread, emailIds: ids, confirmed: true }), + star: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Star, emailIds: ids, confirmed: true }), + unstar: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Unstar, emailIds: ids, confirmed: true }), + archive: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Archive, emailIds: ids, confirmed: true }), + trash: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Trash, emailIds: ids, confirmed: true }), +}; + export const UnsubscribeApi = { detect: () => api.post('/unsubscribe/detect'), safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data), diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx new file mode 100644 index 0000000..ef2bcfe --- /dev/null +++ b/frontend/src/components/BulkToolbar.jsx @@ -0,0 +1,30 @@ +import { BulkApi } from '../api/client.js'; + +/// +/// Toolbar shown above an email list when one or more rows are selected. +/// `onDone` is called with the action key after a successful bulk call so the +/// caller can update local state (e.g. remove trashed/archived rows). +/// +export default function BulkToolbar({ selectedIds, onDone, onClear }) { + const count = selectedIds.length; + if (count === 0) return null; + + const run = (fn, action) => async () => { + await fn(selectedIds); + onDone(action, selectedIds); + onClear(); + }; + + return ( +
+ {count} selected +
+ + + + + + +
+ ); +} diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx index 8fc63fb..d7819b5 100644 --- a/frontend/src/components/EmailRow.jsx +++ b/frontend/src/components/EmailRow.jsx @@ -16,7 +16,7 @@ const fmtSize = (b) => { return `${(b / 1048576).toFixed(1)} MB`; }; -export default function EmailRow({ email: initial, onRemove }) { +export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); @@ -72,6 +72,11 @@ export default function EmailRow({ email: initial, onRemove }) { onClick={openInGmail} title="Open in Gmail" > + {onToggleSelect && ( + e.stopPropagation()}> + onToggleSelect(email.id)} /> + + )} {email.isUnread && } {email.senderDisplayName || email.senderAddress} diff --git a/frontend/src/hooks/useSelection.js b/frontend/src/hooks/useSelection.js new file mode 100644 index 0000000..9fd6c4b --- /dev/null +++ b/frontend/src/hooks/useSelection.js @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; + +/// Tracks a set of selected row IDs for bulk actions. Resets on `resetKey` change. +export default function useSelection() { + const [selected, setSelected] = useState(() => new Set()); + + const toggle = useCallback((id) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const clear = useCallback(() => setSelected(new Set()), []); + + const removeIds = useCallback((ids) => { + setSelected((prev) => { + const next = new Set(prev); + ids.forEach((id) => next.delete(id)); + return next; + }); + }, []); + + return { selected, toggle, clear, removeIds, selectedIds: [...selected] }; +} diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index 2d0107c..fb56f64 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; const FOLDER_META = { inbox: { icon: '📥', label: 'Inbox' }, @@ -43,6 +45,7 @@ export default function FolderView() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); // Reset when the folder changes useEffect(() => { @@ -51,7 +54,8 @@ export default function FolderView() { setPage(1); setHasMore(true); setError(null); - }, [slug]); + clear(); + }, [slug, clear]); // Fetch a page and append results const fetchPage = useCallback((p) => { @@ -98,6 +102,17 @@ export default function FolderView() { {error &&
{error}
} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {!loading && !error && emails.length === 0 && (
No emails in this folder.
)} @@ -109,6 +124,8 @@ export default function FolderView() { setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx index 56c01c9..fb501fe 100644 --- a/frontend/src/pages/SearchResults.jsx +++ b/frontend/src/pages/SearchResults.jsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; const PAGE_SIZE = 50; @@ -16,6 +18,7 @@ export default function SearchResults() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); useEffect(() => { setEmails([]); @@ -23,7 +26,8 @@ export default function SearchResults() { setPage(1); setHasMore(true); setError(null); - }, [q]); + clear(); + }, [q, clear]); const fetchPage = useCallback((p) => { if (!q.trim()) return; @@ -70,6 +74,17 @@ export default function SearchResults() {
No results for "{q}".
)} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {emails.length > 0 && ( @@ -77,6 +92,8 @@ export default function SearchResults() { setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx index 688b53d..e432dc3 100644 --- a/frontend/src/pages/Senders.jsx +++ b/frontend/src/pages/Senders.jsx @@ -1,5 +1,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'; import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -123,7 +125,7 @@ function EmailDetail({ email: summary, onBack }) { // ── EmailListRow (row inside sender email list) ─────────────────────────────── -function EmailListRow({ email: initial, onRemove, onOpen }) { +function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); @@ -150,6 +152,9 @@ function EmailListRow({ email: initial, onRemove, onOpen }) { title="View email" style={{ cursor: 'pointer' }} > +
e.stopPropagation()}> + onToggleSelect(email.id)} /> + {email.isUnread && } {email.subject || '(no subject)'} @@ -195,6 +200,7 @@ function SenderEmails({ sender }) { const [loading, setLoading] = useState(false); const [openEmail, setOpenEmail] = useState(null); const sentinelRef = useRef(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); // Reset when sender changes useEffect(() => { @@ -203,7 +209,8 @@ function SenderEmails({ sender }) { setPage(1); setHasMore(true); setOpenEmail(null); - }, [sender.senderId]); + clear(); + }, [sender.senderId, clear]); const fetchPage = useCallback((p) => { setLoading(true); @@ -245,6 +252,17 @@ function SenderEmails({ sender }) { )} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {emails.length > 0 && ( @@ -252,6 +270,8 @@ function SenderEmails({ sender }) { setEmails((prev) => prev.filter((x) => x.id !== id))} />