feat: sync status indicator, unsubscribe queue UI, email body preview
SyncStatus widget polls /sync/status and shows a live progress bar in
the topbar while syncing, or last-synced time / error otherwise.
Unsubscribe page reworked into a proper queue: status filter tabs
(All/Pending/Succeeded/Failed), select-all, sorted by volume, status
badges, and a result toast after processing.
GET /email/{id} returns full BodyText; Senders detail view fetches
and renders it instead of just the snippet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,21 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||||
|
||||
// ── EmailDetail (full single-email view) ──────────────────────────────────────
|
||||
|
||||
function EmailDetail({ email, onBack }) {
|
||||
function EmailDetail({ email: summary, onBack }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
EmailApi.get(summary.id)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [summary.id]);
|
||||
|
||||
const email = detail ?? summary;
|
||||
|
||||
return (
|
||||
<div className="sd-detail">
|
||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||
@@ -83,9 +97,17 @@ function EmailDetail({ email, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{email.snippet && (
|
||||
|
||||
{loading && <div className="sd-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
<div className="sd-body">{detail.bodyText}</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail?.bodyText && email.snippet && (
|
||||
<div className="sd-snippet">{email.snippet}</div>
|
||||
)}
|
||||
|
||||
<div className="sd-detail-actions">
|
||||
<button
|
||||
className="btn-sm"
|
||||
|
||||
@@ -1,49 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UnsubscribeApi } from '../api/client.js';
|
||||
|
||||
const METHOD = ['None', 'HTTP link', 'mailto', 'One-click'];
|
||||
const METHOD = ['—', 'HTTP link', 'mailto', 'One-click'];
|
||||
const STATUS = ['Detected', 'Queued', 'In progress', 'Succeeded', 'Failed', 'Skipped'];
|
||||
const STATUS_CLASS = ['detected', 'queued', 'progress', 'ok', 'fail', 'skip'];
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'pending', label: 'Pending', statuses: [0, 1, 2] },
|
||||
{ key: 'succeeded', label: 'Succeeded', statuses: [3] },
|
||||
{ key: 'failed', label: 'Failed', statuses: [4] },
|
||||
];
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState('pending');
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const load = async () => setItems(await UnsubscribeApi.safeList());
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const detect = async () => { setBusy(true); try { await UnsubscribeApi.detect(); await load(); } finally { setBusy(false); } };
|
||||
const visible = useMemo(() => {
|
||||
const f = FILTERS.find((f) => f.key === filter);
|
||||
const list = !f?.statuses ? items : items.filter((i) => f.statuses.includes(i.status));
|
||||
return [...list].sort((a, b) => b.emailCount - a.emailCount);
|
||||
}, [items, filter]);
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = { ...prev };
|
||||
visible.forEach((it) => { next[it.id] = !allVisibleSelected; });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const detect = async () => {
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.detect(); await load(); setToast('Scan complete.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const process = async () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
if (!ids.length) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)?`)) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)? This cannot be undone for one-click unsubscribes.`)) return;
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.process({ itemIds: ids, confirmed: true }); await load(); setSelected({}); }
|
||||
finally { setBusy(false); }
|
||||
try {
|
||||
const result = await UnsubscribeApi.process({ itemIds: ids, confirmed: true });
|
||||
await load();
|
||||
setSelected({});
|
||||
setToast(`${result.succeededCount} succeeded, ${result.failedCount} failed.`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page unsub-page">
|
||||
<h2>Unsubscribe Manager</h2>
|
||||
<p className="muted">Review senders you can unsubscribe from, queue them up, and track results.</p>
|
||||
|
||||
<div className="form-row">
|
||||
<button onClick={detect} disabled={busy}>Re-scan for subscriptions</button>
|
||||
<button onClick={process} disabled={busy} className="danger">Unsubscribe selected</button>
|
||||
<button onClick={detect} disabled={busy}>{busy ? 'Scanning…' : 'Re-scan for subscriptions'}</button>
|
||||
<button onClick={process} disabled={busy || selectedCount === 0} className="danger">
|
||||
Unsubscribe selected{selectedCount > 0 ? ` (${selectedCount})` : ''}
|
||||
</button>
|
||||
{toast && <span className="unsub-toast">{toast}</span>}
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead><tr><th></th><th>Sender</th><th>Domain</th><th>Method</th><th>Emails</th><th>Status</th></tr></thead>
|
||||
|
||||
<div className="unsub-tabs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`unsub-tab${filter === f.key ? ' unsub-tab--active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
<span className="unsub-tab-count">
|
||||
{f.statuses ? items.filter((i) => f.statuses.includes(i.status)).length : items.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="grid unsub-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} disabled={visible.length === 0} /></th>
|
||||
<th>Sender</th>
|
||||
<th>Domain</th>
|
||||
<th>Method</th>
|
||||
<th className="num">Emails</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
{visible.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><input type="checkbox" checked={!!selected[it.id]} onChange={(e) => setSelected({ ...selected, [it.id]: e.target.checked })} /></td>
|
||||
<td>{it.senderAddress}</td>
|
||||
<td>{it.domain}</td>
|
||||
<td className="muted">{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount}</td>
|
||||
<td>{STATUS[it.status]}</td>
|
||||
<td className="num">{it.emailCount.toLocaleString()}</td>
|
||||
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan="6" className="muted">Nothing detected yet. Run a scan.</td></tr>}
|
||||
{!visible.length && (
|
||||
<tr><td colSpan="6" className="muted">
|
||||
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user