Files
Inboxintel/frontend/src/pages/Senders.jsx
T
cesnimda a24c48179a 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>
2026-06-30 21:40:01 +02:00

289 lines
10 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}