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' : ''}
+
+ {filtered.map((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 ;
+
+ 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; }