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:
cesnimda
2026-06-30 21:40:01 +02:00
parent 0dfd739430
commit a24c48179a
2 changed files with 356 additions and 74 deletions
+277 -58
View File
@@ -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
View File
@@ -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; }