a24c48179a
Left panel: scrollable sender list with filter, email count, unread badge. Right panel: infinite-scroll email list, quick actions, click row to view detail (subject, meta, snippet, open in Gmail). Back button returns to list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
10 KiB
React
289 lines
10 KiB
React
import { useEffect, useCallback, useRef, useState } from 'react';
|
||
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
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) return '';
|
||
if (b < 1024) return `${b} B`;
|
||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||
return `${(b / 1048576).toFixed(1)} MB`;
|
||
};
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
// ── SenderList (left panel) ───────────────────────────────────────────────────
|
||
|
||
function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||
const filtered = senders.filter((s) => {
|
||
const q = search.toLowerCase();
|
||
return !q || (s.address + ' ' + (s.displayName ?? '')).toLowerCase().includes(q);
|
||
});
|
||
|
||
return (
|
||
<div className="sl-panel">
|
||
<div className="sl-search-wrap">
|
||
<input
|
||
className="sl-search"
|
||
type="search"
|
||
placeholder="Filter senders…"
|
||
value={search}
|
||
onChange={(e) => onSearch(e.target.value)}
|
||
aria-label="Filter senders"
|
||
/>
|
||
</div>
|
||
<div className="sl-count">{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}</div>
|
||
<ul className="sl-list">
|
||
{filtered.map((s) => (
|
||
<li key={s.senderId}>
|
||
<button
|
||
className={`sl-item${selectedId === s.senderId ? ' sl-item--active' : ''}`}
|
||
onClick={() => onSelect(s)}
|
||
>
|
||
<div className="sl-name">{s.displayName || s.address}</div>
|
||
<div className="sl-addr">{s.displayName ? s.address : s.domain}</div>
|
||
<div className="sl-meta">
|
||
<span className="sl-email-count">{s.emailCount.toLocaleString()}</span>
|
||
{s.unreadCount > 0 && <span className="sl-unread">{s.unreadCount} unread</span>}
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── EmailDetail (full single-email view) ──────────────────────────────────────
|
||
|
||
function EmailDetail({ email, onBack }) {
|
||
return (
|
||
<div className="sd-detail">
|
||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||
<div className="sd-detail-header">
|
||
<div className="sd-detail-subject">{email.subject || '(no subject)'}</div>
|
||
<div className="sd-detail-meta">
|
||
<span>{email.senderDisplayName || email.senderAddress}</span>
|
||
<span className="sd-detail-sep">·</span>
|
||
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
|
||
{email.sizeEstimateBytes > 0 && (
|
||
<>
|
||
<span className="sd-detail-sep">·</span>
|
||
<span>{fmtSize(email.sizeEstimateBytes)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{email.snippet && (
|
||
<div className="sd-snippet">{email.snippet}</div>
|
||
)}
|
||
<div className="sd-detail-actions">
|
||
<button
|
||
className="btn-sm"
|
||
onClick={() => window.open(
|
||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||
'_blank', 'noopener,noreferrer'
|
||
)}
|
||
>Open in Gmail ↗</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── EmailListRow (row inside sender email list) ───────────────────────────────
|
||
|
||
function EmailListRow({ email: initial, onRemove, onOpen }) {
|
||
const [email, setEmail] = useState(initial);
|
||
const [acting, setActing] = useState(false);
|
||
|
||
const act = (fn, patch) => async (e) => {
|
||
e.stopPropagation();
|
||
if (acting) return;
|
||
setActing(true);
|
||
try { await fn(email.id); setEmail((p) => ({ ...p, ...patch })); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
const handleTrash = async (e) => {
|
||
e.stopPropagation();
|
||
if (acting) return;
|
||
setActing(true);
|
||
try { await EmailApi.trash(email.id); onRemove?.(email.id); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
return (
|
||
<tr
|
||
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}`}
|
||
onClick={() => onOpen(email)}
|
||
title="View email"
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||
<td className="el-subject">
|
||
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||
{email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||
</td>
|
||
<td className="el-meta">
|
||
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||
</td>
|
||
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
className="action-btn"
|
||
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||
onClick={email.isUnread
|
||
? act(EmailApi.markRead, { isUnread: false })
|
||
: act(EmailApi.markUnread, { isUnread: true })}
|
||
>{email.isUnread ? '✓' : '●'}</button>
|
||
<button
|
||
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||
title={email.isStarred ? 'Unstar' : 'Star'}
|
||
onClick={email.isStarred
|
||
? act(EmailApi.unstar, { isStarred: false })
|
||
: act(EmailApi.star, { isStarred: true })}
|
||
>⭐</button>
|
||
<button
|
||
className="action-btn action-btn--danger"
|
||
title="Move to trash"
|
||
onClick={handleTrash}
|
||
>🗑️</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ── SenderEmails (right panel) ────────────────────────────────────────────────
|
||
|
||
function SenderEmails({ sender }) {
|
||
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 [openEmail, setOpenEmail] = useState(null);
|
||
const sentinelRef = useRef(null);
|
||
|
||
// Reset when sender changes
|
||
useEffect(() => {
|
||
setEmails([]);
|
||
setTotalCount(null);
|
||
setPage(1);
|
||
setHasMore(true);
|
||
setOpenEmail(null);
|
||
}, [sender.senderId]);
|
||
|
||
const fetchPage = useCallback((p) => {
|
||
setLoading(true);
|
||
SearchApi.query(`from:${sender.address}`, p, PAGE_SIZE)
|
||
.then((r) => {
|
||
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
|
||
setTotalCount(r.totalCount);
|
||
setHasMore(p < r.totalPages);
|
||
setPage(p);
|
||
})
|
||
.finally(() => setLoading(false));
|
||
}, [sender]);
|
||
|
||
useEffect(() => { fetchPage(1); }, [fetchPage]);
|
||
|
||
// Infinite scroll sentinel
|
||
useEffect(() => {
|
||
const el = sentinelRef.current;
|
||
if (!el) return;
|
||
const obs = new IntersectionObserver(
|
||
(entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1); },
|
||
{ rootMargin: '200px' }
|
||
);
|
||
obs.observe(el);
|
||
return () => obs.disconnect();
|
||
}, [hasMore, loading, page, fetchPage]);
|
||
|
||
if (openEmail) {
|
||
return <EmailDetail email={openEmail} onBack={() => setOpenEmail(null)} />;
|
||
}
|
||
|
||
return (
|
||
<div className="sd-panel">
|
||
<div className="sd-panel-header">
|
||
<div className="sd-panel-name">{sender.displayName || sender.address}</div>
|
||
<div className="sd-panel-addr">{sender.displayName ? sender.address : ''}</div>
|
||
{totalCount !== null && (
|
||
<div className="sd-panel-count">{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}</div>
|
||
)}
|
||
</div>
|
||
|
||
{emails.length > 0 && (
|
||
<table className="email-list">
|
||
<tbody>
|
||
{emails.map((e) => (
|
||
<EmailListRow
|
||
key={e.id}
|
||
email={e}
|
||
onOpen={setOpenEmail}
|
||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
<div ref={sentinelRef} />
|
||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||
{!loading && !hasMore && emails.length > 0 && (
|
||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||
)}
|
||
{!loading && emails.length === 0 && (
|
||
<div className="fv-empty">No emails found from this sender.</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function Senders() {
|
||
const [senders, setSenders] = useState(null);
|
||
const [selected, setSelected] = useState(null);
|
||
const [search, setSearch] = useState('');
|
||
|
||
useEffect(() => {
|
||
AnalyticsApi.allSenders().then((list) => {
|
||
setSenders(list);
|
||
if (list.length > 0) setSelected(list[0]);
|
||
}).catch(() => setSenders([]));
|
||
}, []);
|
||
|
||
if (!senders) return <div className="page"><h2>Senders</h2><p className="muted">Loading…</p></div>;
|
||
|
||
if (senders.length === 0) return (
|
||
<div className="page">
|
||
<h2>Senders</h2>
|
||
<p className="muted">No senders yet — run a sync first.</p>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="senders-master-detail">
|
||
<SenderList
|
||
senders={senders}
|
||
selectedId={selected?.senderId}
|
||
onSelect={setSelected}
|
||
search={search}
|
||
onSearch={setSearch}
|
||
/>
|
||
{selected && <SenderEmails key={selected.senderId} sender={selected} />}
|
||
</div>
|
||
);
|
||
}
|