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:
cesnimda
2026-06-30 21:50:59 +02:00
parent a24c48179a
commit 8c92263cc3
8 changed files with 293 additions and 18 deletions
+24 -2
View File
@@ -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"
+88 -16
View File
@@ -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>