feat: senders master-detail layout with inline email view
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>
This commit is contained in:
+277
-58
@@ -1,69 +1,288 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
|
||||
|
||||
// Volume tiers, highest first.
|
||||
const TIERS = [
|
||||
{ key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' },
|
||||
{ key: '500-1000', label: '500 – 1000', min: 500, max: 999, accent: '#f2994a' },
|
||||
{ key: '250-500', label: '250 – 500', min: 250, max: 499, accent: '#f2c94c' },
|
||||
{ key: '100-250', label: '100 – 250', min: 100, max: 249, accent: '#56ccf2' },
|
||||
{ key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' }
|
||||
];
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PER_TIER = 60; // cap visible blocks per tier to keep it light
|
||||
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' });
|
||||
};
|
||||
|
||||
export default function Senders() {
|
||||
const [senders, setSenders] = useState(null);
|
||||
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`;
|
||||
};
|
||||
|
||||
useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const buckets = useMemo(() => {
|
||||
const map = Object.fromEntries(TIERS.map((t) => [t.key, []]));
|
||||
(senders || []).forEach((s) => {
|
||||
const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max);
|
||||
if (tier) map[tier.key].push(s);
|
||||
});
|
||||
Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount));
|
||||
return map;
|
||||
}, [senders]);
|
||||
// ── SenderList (left panel) ───────────────────────────────────────────────────
|
||||
|
||||
if (!senders) return <div className="page"><h2>Senders by Volume</h2><p className="muted">Loading…</p></div>;
|
||||
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="page senders-page">
|
||||
<h2>Senders by Volume</h2>
|
||||
<p className="muted">Who fills your inbox, grouped by how many emails they've sent you.</p>
|
||||
|
||||
{TIERS.map((tier) => {
|
||||
const list = buckets[tier.key];
|
||||
if (!list.length) return null;
|
||||
const shown = list.slice(0, PER_TIER);
|
||||
return (
|
||||
<section className="tier" key={tier.key}>
|
||||
<div className="tier-head">
|
||||
<span className="tier-dot" style={{ background: tier.accent }} />
|
||||
<h3>{tier.label}</h3>
|
||||
<span className="tier-count">{list.length} sender{list.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="sender-blocks">
|
||||
{shown.map((s) => (
|
||||
<div className="sender-block" key={s.senderId} style={{ borderTopColor: tier.accent }}>
|
||||
<div className="sb-name" title={s.address}>{s.displayName || s.address}</div>
|
||||
<div className="sb-domain">{s.domain}</div>
|
||||
<div className="sb-stats">
|
||||
<span className="sb-count">{s.emailCount.toLocaleString()}</span>
|
||||
{s.unreadCount > 0 && <span className="sb-unread">{s.unreadCount} unread</span>}
|
||||
{s.hasUnsubscribe && <span className="sb-unsub">unsub</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{list.length > PER_TIER && <div className="muted tier-more">+ {list.length - PER_TIER} more</div>}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{senders.length === 0 && <p className="muted">No senders yet — run a sync first.</p>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+79
-16
@@ -160,22 +160,85 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; }
|
||||
|
||||
/* Senders by volume */
|
||||
.senders-page { max-width: 1100px; }
|
||||
.tier { margin-top: 22px; }
|
||||
.tier-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.tier-head h3 { margin: 0; font-size: 16px; }
|
||||
.tier-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.tier-count { color: var(--muted); font-size: 12px; }
|
||||
.sender-blocks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||
.sender-block { background: var(--panel); border: 1px solid #2c3550; border-top: 3px solid var(--accent); border-radius: 8px; padding: 10px 12px; }
|
||||
.sb-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sb-domain { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
|
||||
.sb-stats { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.sb-count { font-size: 20px; font-weight: 700; }
|
||||
.sb-unread { font-size: 11px; color: var(--accent); }
|
||||
.sb-unsub { font-size: 10px; color: var(--muted); border: 1px solid #36405c; border-radius: 4px; padding: 0 4px; }
|
||||
.tier-more { margin-top: 8px; }
|
||||
/* ── Senders master-detail ─────────────────────────────────────────────── */
|
||||
.senders-master-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: calc(100vh - 56px); /* fill below topbar */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel — sender list */
|
||||
.sl-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #2c3550;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sl-search-wrap { padding: 10px 12px 6px; }
|
||||
.sl-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid #2c3550;
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sl-count { padding: 0 14px 6px; font-size: 11px; color: var(--muted); }
|
||||
.sl-list { flex: 1; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.sl-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 10px 14px; border-bottom: 1px solid #1e2540;
|
||||
color: var(--text);
|
||||
}
|
||||
.sl-item:hover { background: var(--panel-2); }
|
||||
.sl-item--active { background: var(--panel-2); border-left: 3px solid var(--accent); padding-left: 11px; }
|
||||
.sl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sl-addr { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 3px; }
|
||||
.sl-meta { display: flex; align-items: baseline; gap: 8px; }
|
||||
.sl-email-count { font-size: 13px; font-weight: 700; color: var(--accent); }
|
||||
.sl-unread { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* Right panel — email list for selected sender */
|
||||
.sd-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.sd-panel-header {
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid #2c3550;
|
||||
background: var(--panel);
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
.sd-panel-name { font-size: 16px; font-weight: 700; }
|
||||
.sd-panel-addr { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.sd-panel-count { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Single email detail view */
|
||||
.sd-detail { padding: 20px; max-width: 760px; }
|
||||
.sd-back {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: none; border: 1px solid #2c3550; border-radius: 6px;
|
||||
color: var(--text); font-size: 13px; cursor: pointer;
|
||||
padding: 5px 12px; margin-bottom: 18px;
|
||||
}
|
||||
.sd-back:hover { background: var(--panel-2); }
|
||||
.sd-detail-header { margin-bottom: 14px; }
|
||||
.sd-detail-subject { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
|
||||
.sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.sd-detail-sep { opacity: 0.4; }
|
||||
.sd-snippet { background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
|
||||
.sd-detail-actions { display: flex; gap: 10px; }
|
||||
.btn-sm {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
||||
padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.btn-sm:hover { opacity: 0.85; }
|
||||
|
||||
.page { max-width: 900px; }
|
||||
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
||||
|
||||
Reference in New Issue
Block a user