diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx index d7cdccf..6f74d63 100644 --- a/frontend/src/pages/Senders.jsx +++ b/frontend/src/pages/Senders.jsx @@ -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

Senders by Volume

Loading…

; +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 ( -
-

Senders by Volume

-

Who fills your inbox, grouped by how many emails they've sent you.

- - {TIERS.map((tier) => { - const list = buckets[tier.key]; - if (!list.length) return null; - const shown = list.slice(0, PER_TIER); - return ( -
-
- -

{tier.label}

- {list.length} sender{list.length === 1 ? '' : 's'} -
-
- {shown.map((s) => ( -
-
{s.displayName || s.address}
-
{s.domain}
-
- {s.emailCount.toLocaleString()} - {s.unreadCount > 0 && {s.unreadCount} unread} - {s.hasUnsubscribe && unsub} -
-
- ))} -
- {list.length > PER_TIER &&
+ {list.length - PER_TIER} more
} -
- ); - })} - - {senders.length === 0 &&

No senders yet — run a sync first.

} +
+
+ onSearch(e.target.value)} + aria-label="Filter senders" + /> +
+
{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}
+ +
+ ); +} + +// ── EmailDetail (full single-email view) ────────────────────────────────────── + +function EmailDetail({ email, onBack }) { + return ( +
+ +
+
{email.subject || '(no subject)'}
+
+ {email.senderDisplayName || email.senderAddress} + · + {new Date(email.sentAtUtc).toLocaleString()} + {email.sizeEstimateBytes > 0 && ( + <> + · + {fmtSize(email.sizeEstimateBytes)} + + )} +
+
+ {email.snippet && ( +
{email.snippet}
+ )} +
+ +
+
+ ); +} + +// ── 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 ( + onOpen(email)} + title="View email" + style={{ cursor: 'pointer' }} + > + {email.isUnread && } + + {email.subject || '(no subject)'} + {email.snippet && — {email.snippet}} + + + {email.hasAttachments && 📎} + {email.sizeEstimateBytes > 1048576 && {fmtSize(email.sizeEstimateBytes)}} + + {fmtDate(email.sentAtUtc)} + e.stopPropagation()}> + + + + + + ); +} + +// ── 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 setOpenEmail(null)} />; + } + + return ( +
+
+
{sender.displayName || sender.address}
+
{sender.displayName ? sender.address : ''}
+ {totalCount !== null && ( +
{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}
+ )} +
+ + {emails.length > 0 && ( + + + {emails.map((e) => ( + setEmails((prev) => prev.filter((x) => x.id !== id))} + /> + ))} + +
+ )} + +
+ {loading &&
Loading…
} + {!loading && !hasMore && emails.length > 0 && ( +
— {emails.length.toLocaleString()} emails —
+ )} + {!loading && emails.length === 0 && ( +
No emails found from this sender.
+ )} +
+ ); +} + +// ── 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

Senders

Loading…

; + + if (senders.length === 0) return ( +
+

Senders

+

No senders yet — run a sync first.

+
+ ); + + return ( +
+ + {selected && }
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8e52555..5bdbcc1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }