From 19cc9ca88ad4487532408f2daf553eed2779aa1b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 4 Jul 2026 16:51:42 +0200 Subject: [PATCH] feat(ui): split-view reading pane for FolderView + SearchResults Clicking an email now opens a collapsible, horizontally resizable reading pane beside the list instead of a new tab. Extracts the shared EmailDetail component (full detail fetch + lazy AI summary + Open in Gmail) and adds list skeleton + empty states via the ui primitives. - New components/EmailDetail.jsx (emailId/email + onClose) - New split.css (flex master-detail, native resize, <768px overlay) - FolderView/SearchResults: selectedEmail state, onOpen wiring, Skeleton loading rows, EmptyState zero-states - main.jsx imports split.css Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/EmailDetail.jsx | 115 ++++++++++++++++++ frontend/src/main.jsx | 1 + frontend/src/pages/FolderView.jsx | 91 +++++++++++---- frontend/src/pages/SearchResults.jsx | 97 ++++++++++++---- frontend/src/split.css | 148 ++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/EmailDetail.jsx create mode 100644 frontend/src/split.css diff --git a/frontend/src/components/EmailDetail.jsx b/frontend/src/components/EmailDetail.jsx new file mode 100644 index 0000000..8671efd --- /dev/null +++ b/frontend/src/components/EmailDetail.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { EmailApi } from '../api/client.js'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +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`; +}; + +/** + * Shared reading-pane / full single-email view. + * + * Accepts either an `email` summary object (as emitted by the list rows) or a + * bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI + * summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI + * summary button, the body, and per-email actions including "Open in Gmail". + * + * `onClose` collapses the pane. + */ +export default function EmailDetail({ email: summary, emailId, onClose }) { + const id = summary?.id ?? emailId; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [aiSummary, setAiSummary] = useState(null); + const [aiLoading, setAiLoading] = useState(false); + + useEffect(() => { + if (id == null) return; + let cancelled = false; + setDetail(null); + setLoading(true); + setAiSummary(null); + setAiLoading(false); + EmailApi.get(id) + .then((d) => { if (!cancelled) setDetail(d); }) + .catch(() => { if (!cancelled) setDetail(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [id]); + + const email = detail ?? summary ?? {}; + + const fetchAiSummary = () => { + if (id == null) return; + setAiLoading(true); + EmailApi.summary(id) + .then((r) => setAiSummary(r.summary)) + .catch(() => setAiSummary(null)) + .finally(() => setAiLoading(false)); + }; + + const openInGmail = () => window.open( + `https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`, + '_blank', 'noopener,noreferrer' + ); + + return ( +
+
+ +
+ +
+
{email.subject || '(no subject)'}
+
+ {email.senderDisplayName || email.senderAddress} + {email.sentAtUtc && ( + <> + · + {new Date(email.sentAtUtc).toLocaleString()} + + )} + {email.sizeEstimateBytes > 0 && ( + <> + · + {fmtSize(email.sizeEstimateBytes)} + + )} +
+
+ + {!loading && ( +
+ {aiSummary != null ? ( +
✨ {aiSummary}
+ ) : ( + + )} +
+ )} + + {loading &&
Loading message…
} + + {!loading && detail?.bodyText && ( +
{detail.bodyText}
+ )} + + {!loading && !detail?.bodyText && email.snippet && ( +
{email.snippet}
+ )} + +
+ +
+
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index dbf90ae..f82e11c 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -12,6 +12,7 @@ import Layout from './components/Layout.jsx'; import { ToastProvider, TooltipProvider } from './components/ui'; import './index.css'; import './styles.css'; +import './split.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index da35f92..d980d33 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -2,7 +2,9 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import EmailDetail from '../components/EmailDetail.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; +import { Skeleton, EmptyState } from '../components/ui'; import useSelection from '../hooks/useSelection.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; @@ -35,6 +37,20 @@ const FOLDER_META = { const PAGE_SIZE = 50; +function ListSkeleton({ rows = 6 }) { + return ( + + ); +} + export default function FolderView() { const { slug } = useParams(); @@ -46,6 +62,7 @@ export default function FolderView() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selectedEmail, setSelectedEmail] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); @@ -56,6 +73,7 @@ export default function FolderView() { setPage(1); setHasMore(true); setError(null); + setSelectedEmail(null); clear(); }, [slug, clear]); @@ -115,34 +133,57 @@ export default function FolderView() { }} /> - {!loading && !error && emails.length === 0 && ( -
No emails in this folder.
- )} +
+
+ {loading && emails.length === 0 && !error && } - {emails.length > 0 && ( - - - {emails.map((e) => ( - setEmails((prev) => prev.filter((x) => x.id !== id))} - /> - ))} - -
- )} + {!loading && !error && emails.length === 0 && ( + + )} - {/* Sentinel — triggers next page load when scrolled into view */} -
+ {emails.length > 0 && ( + + + {emails.map((e) => ( + setSelectedEmail(email)} + onRemove={(id) => { + setEmails((prev) => prev.filter((x) => x.id !== id)); + setSelectedEmail((cur) => (cur?.id === id ? null : cur)); + }} + /> + ))} + +
+ )} - {loading &&
Loading…
} - {!hasMore && emails.length > 0 && ( -
— {emails.length.toLocaleString()} emails —
- )} + {/* Sentinel — triggers next page load when scrolled into view */} +
+ + {loading && emails.length > 0 &&
Loading…
} + {!hasMore && emails.length > 0 && ( +
— {emails.length.toLocaleString()} emails —
+ )} +
+ + {selectedEmail && ( + + )} +
); } diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx index 31115f7..24d2e23 100644 --- a/frontend/src/pages/SearchResults.jsx +++ b/frontend/src/pages/SearchResults.jsx @@ -2,13 +2,29 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import EmailDetail from '../components/EmailDetail.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; +import { Skeleton, EmptyState } from '../components/ui'; import useSelection from '../hooks/useSelection.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useSavedSearches from '../hooks/useSavedSearches.js'; const PAGE_SIZE = 50; +function ListSkeleton({ rows = 6 }) { + return ( + + ); +} + export default function SearchResults() { const [searchParams] = useSearchParams(); @@ -20,6 +36,7 @@ export default function SearchResults() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selectedEmail, setSelectedEmail] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const { searches: savedSearches, add: addSavedSearch } = useSavedSearches(); @@ -36,6 +53,7 @@ export default function SearchResults() { setPage(1); setHasMore(true); setError(null); + setSelectedEmail(null); clear(); }, [q, clear]); @@ -83,11 +101,13 @@ export default function SearchResults() { )}
- {!q.trim() &&
Enter a search query above.
} - {error &&
{error}
} - {!loading && !error && q.trim() && emails.length === 0 && !hasMore && ( -
No results for "{q}".
+ {!q.trim() && ( + )} + {error &&
{error}
} - {emails.length > 0 && ( - - - {emails.map((e) => ( - setEmails((prev) => prev.filter((x) => x.id !== id))} - /> - ))} - -
- )} + {q.trim() && ( +
+
+ {loading && emails.length === 0 && !error && } -
- {loading &&
Loading…
} - {!hasMore && emails.length > 0 && ( -
— {emails.length.toLocaleString()} results —
+ {!loading && !error && emails.length === 0 && !hasMore && ( + + )} + + {emails.length > 0 && ( + + + {emails.map((e) => ( + setSelectedEmail(email)} + onRemove={(id) => { + setEmails((prev) => prev.filter((x) => x.id !== id)); + setSelectedEmail((cur) => (cur?.id === id ? null : cur)); + }} + /> + ))} + +
+ )} + +
+ {loading && emails.length > 0 &&
Loading…
} + {!hasMore && emails.length > 0 && ( +
— {emails.length.toLocaleString()} results —
+ )} +
+ + {selectedEmail && ( + + )} +
)}
); diff --git a/frontend/src/split.css b/frontend/src/split.css new file mode 100644 index 0000000..2e8b70a --- /dev/null +++ b/frontend/src/split.css @@ -0,0 +1,148 @@ +/* ── Split-view reading pane ──────────────────────────────────────────────── + * Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on + * the left, a collapsible + horizontally resizable reading pane sits on the + * right. Below 768px the pane overlays the list (mobile stack) instead of + * squishing the columns side-by-side. + * + * These pages (.folder-view) are styled from styles.css, so we reference its + * legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with + * hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL + * channel triplets and only work via hsl(var(--x)), so they are NOT used bare + * here. + * ------------------------------------------------------------------------- */ + +.sv-split { + display: flex; + align-items: stretch; + gap: 0; + width: 100%; +} + +/* Left column — the list. Flexes to fill remaining space and scrolls itself. */ +.sv-list-pane { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; +} + +/* Right column — the reading pane. Resizable via the native handle; the width + * gives it a sensible default, and the user can drag the bottom-right corner. + * `resize: horizontal` needs `overflow` other than visible. */ +.sv-reading-pane { + flex: 0 0 auto; + width: clamp(320px, 42%, 640px); + min-width: 300px; + max-width: 80vw; + border-left: 1px solid var(--panel-2, #222a3d); + overflow: auto; + resize: horizontal; + background: var(--panel, #1a2030); + align-self: stretch; +} + +/* ── Reading pane inner content ─────────────────────────────────────────── */ +.sv-detail { padding: 18px 20px; } + +.sv-detail-topbar { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} +.sv-close { + background: transparent; + border: 1px solid var(--panel-2, #222a3d); + color: var(--muted, #8b93a7); + border-radius: 6px; + width: 28px; + height: 28px; + font-size: 13px; + line-height: 1; + cursor: pointer; +} +.sv-close:hover { + background: var(--panel-2, #222a3d); + color: var(--text, #e6e9f0); +} + +.sv-detail-header { margin-bottom: 14px; } +.sv-detail-subject { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; + color: var(--text, #e6e9f0); + word-break: break-word; +} +.sv-detail-meta { + font-size: 12px; + color: var(--muted, #8b93a7); + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} +.sv-detail-sep { opacity: 0.4; } + +.sv-ai-summary { margin-bottom: 14px; } +.sv-ai-summary-text { + background: var(--panel-2, #222a3d); + border: 1px solid var(--panel-2, #222a3d); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + color: var(--text, #e6e9f0); +} + +.sv-body-loading { padding: 8px 0 18px; } +.sv-body { + background: var(--panel, #1a2030); + border: 1px solid var(--panel-2, #222a3d); + border-radius: 8px; + padding: 16px 18px; + font-size: 13px; + line-height: 1.7; + color: var(--text, #e6e9f0); + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 18px; + max-height: 60vh; + overflow-y: auto; +} +.sv-snippet { + font-size: 13px; + line-height: 1.6; + color: var(--muted, #8b93a7); + margin-bottom: 18px; +} +.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; } + +/* ── List loading / empty states ───────────────────────────────────────── */ +.sv-skeleton-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; +} +.sv-skeleton-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 8px; +} + +/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */ +@media (max-width: 767px) { + .sv-reading-pane { + position: fixed; + inset: 56px 0 0 0; /* below the topbar */ + width: 100% !important; + max-width: 100vw; + min-width: 0; + border-left: none; + resize: none; + z-index: 40; + } + /* When the pane is open, hide the underlying list to avoid double-scroll. */ + .sv-split.sv-split--open .sv-list-pane { + display: none; + } +} -- 2.52.0