diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 68c57e3..14ab996 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -119,6 +119,7 @@ function folderToRequest(slug, page, pageSize) { } export const EmailApi = { + get: (id) => api.get(`/email/${id}`).then((r) => r.data), markRead: (id) => api.post(`/email/${id}/read`), markUnread: (id) => api.post(`/email/${id}/unread`), star: (id) => api.post(`/email/${id}/star`), diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index fa3c0f8..777883c 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from 'react'; import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js'; import Logo from './Logo.jsx'; import DevBanner from './DevBanner.jsx'; +import SyncStatus from './SyncStatus.jsx'; // ── Folder definitions ──────────────────────────────────────────────────────── @@ -236,6 +237,7 @@ export default function Layout() {
+ {user?.email} diff --git a/frontend/src/components/SyncStatus.jsx b/frontend/src/components/SyncStatus.jsx new file mode 100644 index 0000000..049cd28 --- /dev/null +++ b/frontend/src/components/SyncStatus.jsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { SyncApi } from '../api/client.js'; + +const POLL_INTERVAL_IDLE = 60_000; // 1 min when not syncing +const POLL_INTERVAL_ACTIVE = 2_000; // 2 sec while running + +function fmtAge(iso) { + if (!iso) return null; + const mins = Math.floor((Date.now() - new Date(iso)) / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +export default function SyncStatus() { + const [progress, setProgress] = useState(null); + + useEffect(() => { + let timer; + + const fetch = () => { + SyncApi.status().then((p) => { + setProgress(p); + const next = p.isRunning ? POLL_INTERVAL_ACTIVE : POLL_INTERVAL_IDLE; + timer = setTimeout(fetch, next); + }).catch(() => { + timer = setTimeout(fetch, POLL_INTERVAL_IDLE); + }); + }; + + fetch(); + + // Also refresh when a sync is manually kicked off + const handler = () => { clearTimeout(timer); fetch(); }; + window.addEventListener('inboxintel:sync-started', handler); + + return () => { clearTimeout(timer); window.removeEventListener('inboxintel:sync-started', handler); }; + }, []); + + if (!progress) return null; + + if (progress.isRunning) { + const pct = progress.total > 0 + ? Math.round((progress.processed / progress.total) * 100) + : null; + return ( +
+ + + Syncing{pct !== null ? ` ${pct}%` : '…'} + + {progress.total > 0 && ( + + + + )} +
+ ); + } + + const age = fmtAge(progress.lastSuccessfulSyncUtc); + return ( +
+ {progress.lastError + ? ⚠ Sync error + : {age ? `Synced ${age}` : 'Never synced'}} +
+ ); +} diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx index 6f74d63..688b53d 100644 --- a/frontend/src/pages/Senders.jsx +++ b/frontend/src/pages/Senders.jsx @@ -65,7 +65,21 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) { // ── EmailDetail (full single-email view) ────────────────────────────────────── -function EmailDetail({ email, onBack }) { +function EmailDetail({ email: summary, onBack }) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setDetail(null); + setLoading(true); + EmailApi.get(summary.id) + .then(setDetail) + .catch(() => setDetail(null)) + .finally(() => setLoading(false)); + }, [summary.id]); + + const email = detail ?? summary; + return (
@@ -83,9 +97,17 @@ function EmailDetail({ email, onBack }) { )}
- {email.snippet && ( + + {loading &&
Loading message…
} + + {!loading && detail?.bodyText && ( +
{detail.bodyText}
+ )} + + {!loading && !detail?.bodyText && email.snippet && (
{email.snippet}
)} +
- + + + {toast && {toast}}
- - + +
+ {FILTERS.map((f) => ( + + ))} +
+ +
SenderDomainMethodEmailsStatus
+ + + + + + + + + + - {items.map((it) => ( + {visible.map((it) => ( - + - - + + ))} - {!items.length && } + {!visible.length && ( + + )}
SenderDomainMethodEmailsStatus
setSelected({ ...selected, [it.id]: e.target.checked })} /> {it.senderAddress}{it.domain}{it.domain} {METHOD[it.method]}{it.emailCount}{STATUS[it.status]}{it.emailCount.toLocaleString()}{STATUS[it.status]}
Nothing detected yet. Run a scan.
+ {items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'} +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5bdbcc1..b568451 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -339,3 +339,74 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va .progress-fill[data-indeterminate="true"] { animation: indet 1.2s ease-in-out infinite; } @keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } .progress-label { color: var(--muted); font-size: 13px; } + +/* ── Sync status (topbar) ──────────────────────────────────────────────── */ +.sync-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin-right: 4px; } +.sync-status--error { color: var(--danger); } +.sync-label--err { color: var(--danger); } +.sync-spinner { + width: 10px; height: 10px; border-radius: 50%; + border: 2px solid #36405c; border-top-color: var(--accent); + animation: sync-spin 0.7s linear infinite; +} +@keyframes sync-spin { to { transform: rotate(360deg); } } +.sync-bar-wrap { width: 50px; height: 4px; background: var(--panel-2); border-radius: 3px; overflow: hidden; } +.sync-bar { display: block; height: 100%; background: var(--accent); transition: width 0.3s ease; } + +/* ── Email detail body (Senders page) ──────────────────────────────────── */ +.sd-body-loading { padding: 8px 0 18px; } +.sd-body { + background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; + padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text); + white-space: pre-wrap; word-break: break-word; margin-bottom: 18px; + max-height: 60vh; overflow-y: auto; +} + +/* ── Unsubscribe queue ──────────────────────────────────────────────────── */ +.unsub-page { max-width: 1000px; } +.unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; } +.unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; } +.unsub-tab { + background: var(--panel); border: 1px solid #2c3550; color: var(--muted); + border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; + display: flex; align-items: center; gap: 6px; +} +.unsub-tab:hover { color: var(--text); } +.unsub-tab--active { color: var(--text); border-color: var(--accent); background: var(--panel-2); } +.unsub-tab-count { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 8px; padding: 1px 6px; } +.unsub-grid { width: 100%; } +.unsub-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; border: 1px solid #36405c; } +.unsub-badge--detected { color: var(--muted); } +.unsub-badge--queued { color: #f2c94c; border-color: #f2c94c66; } +.unsub-badge--progress { color: var(--accent); border-color: var(--accent); } +.unsub-badge--ok { color: var(--ok); border-color: var(--ok); } +.unsub-badge--fail { color: var(--danger); border-color: var(--danger); } +.unsub-badge--skip { color: var(--muted); } + +/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */ +.bulk-toolbar { + display: flex; align-items: center; gap: 10px; + background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; + padding: 8px 14px; margin-bottom: 10px; font-size: 13px; +} +.bulk-toolbar .muted { font-size: 12px; } +.bulk-toolbar-spacer { flex: 1; } +.bulk-btn { + background: var(--panel); border: 1px solid #2c3550; color: var(--text); + border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; +} +.bulk-btn:hover { border-color: var(--accent); } +.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); } +.el-select { width: 28px; text-align: center; } + +/* ── Keyboard shortcut help ──────────────────────────────────────────────── */ +.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #2c3550; border-radius: 6px; padding: 4px 10px; opacity: 0.7; } +.kbd-hint kbd { background: var(--panel-2); border: 1px solid #36405c; border-radius: 3px; padding: 0 4px; font-family: inherit; } + +/* ── Saved searches ─────────────────────────────────────────────────────── */ +.saved-search-row { display: flex; align-items: center; gap: 8px; } +.saved-search-save-btn { + background: none; border: 1px solid #2c3550; color: var(--muted); + border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; +} +.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); } diff --git a/src/InboxIntel.Api/Controllers/EmailController.cs b/src/InboxIntel.Api/Controllers/EmailController.cs index 10a5e7d..42cb4e0 100644 --- a/src/InboxIntel.Api/Controllers/EmailController.cs +++ b/src/InboxIntel.Api/Controllers/EmailController.cs @@ -27,6 +27,21 @@ public class EmailController : ApiControllerBase _db = db; } + /// Full email detail including body text, for the inline detail view. + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) + { + var e = await _db.Emails + .Where(e => e.Id == id && e.UserId == UserId) + .Select(e => new EmailDetailDto( + e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.BodyText, + e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, + e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, + e.Category, e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)) + .FirstOrDefaultAsync(ct); + return e is null ? NotFound() : Ok(e); + } + [HttpPost("{id:guid}/read")] public Task MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct); diff --git a/src/InboxIntel.Application/DTOs/EmailDtos.cs b/src/InboxIntel.Application/DTOs/EmailDtos.cs index 3c08ed9..98effec 100644 --- a/src/InboxIntel.Application/DTOs/EmailDtos.cs +++ b/src/InboxIntel.Application/DTOs/EmailDtos.cs @@ -18,6 +18,24 @@ public record EmailSummaryDto( bool HasListUnsubscribe, bool SupportsOneClick); +/// Full single-email view, including body text, for the detail pane. +public record EmailDetailDto( + Guid Id, + string GmailMessageId, + string? Subject, + string? Snippet, + string? BodyText, + string SenderAddress, + string? SenderDisplayName, + DateTimeOffset SentAtUtc, + bool IsUnread, + bool IsStarred, + bool HasAttachments, + long SizeEstimateBytes, + EmailCategory Category, + bool HasListUnsubscribe, + bool SupportsOneClick); + public record SenderStatDto( Guid SenderId, string Address,