ui improvements
This commit is contained in:
parent
cce9a8c9ba
commit
78aac5a925
7 changed files with 769 additions and 138 deletions
|
|
@ -1,12 +1,17 @@
|
|||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Filter,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getEmail,
|
||||
listEmails,
|
||||
|
|
@ -16,11 +21,19 @@ import {
|
|||
setEmailStar,
|
||||
trashEmail,
|
||||
} from "../api";
|
||||
import AgAgentPanel from "./AgAgentPanel";
|
||||
import MailDetail from "./MailDetail";
|
||||
import MailRow from "./MailRow";
|
||||
import StickyNotesPanel from "./StickyNotesPanel";
|
||||
import {
|
||||
categoryCounts,
|
||||
emailCategory,
|
||||
inboxIntelligence,
|
||||
matchesLens,
|
||||
} from "../utils/inboxSignals";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const CATEGORIES = ["Primary", "Updates", "Promotions", "Social", "Finance", "All Mail"];
|
||||
|
||||
function resultPage(result) {
|
||||
if (Array.isArray(result)) return { items: result, nextCursor: null };
|
||||
|
|
@ -30,17 +43,30 @@ function resultPage(result) {
|
|||
};
|
||||
}
|
||||
|
||||
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||
export default function MailboxView({
|
||||
mailbox,
|
||||
title,
|
||||
refreshKey = 0,
|
||||
account,
|
||||
lens = "all",
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
onStats,
|
||||
}) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [nextCursor, setNextCursor] = useState(null);
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [cursorHistory, setCursorHistory] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [checked, setChecked] = useState({});
|
||||
const [hidden, setHidden] = useState({});
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: true, error: "" });
|
||||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [category, setCategory] = useState("Primary");
|
||||
const [agentFocus, setAgentFocus] = useState("");
|
||||
const [filters, setFilters] = useState({
|
||||
query: "",
|
||||
read: "all",
|
||||
|
|
@ -79,6 +105,39 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
|||
return () => window.clearTimeout(timeout);
|
||||
}, [load, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
function focusSearch(event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
document.getElementById("workspace-search")?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", focusSearch);
|
||||
return () => window.removeEventListener("keydown", focusSearch);
|
||||
}, []);
|
||||
|
||||
const visibleEmails = useMemo(
|
||||
() => emails.filter((email) => !hidden[email.id] && matchesLens(email, lens)),
|
||||
[emails, hidden, lens],
|
||||
);
|
||||
|
||||
const listedEmails = useMemo(() => {
|
||||
if (mailbox !== "inbox" || category === "All Mail") return visibleEmails;
|
||||
return visibleEmails.filter((email) => emailCategory(email) === category);
|
||||
}, [category, mailbox, visibleEmails]);
|
||||
|
||||
const counts = useMemo(() => categoryCounts(visibleEmails), [visibleEmails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onStats) return;
|
||||
const intel = inboxIntelligence(emails);
|
||||
onStats({
|
||||
unread: emails.filter((email) => email.is_read === false).length,
|
||||
starred: emails.filter((email) => email.is_starred).length,
|
||||
important: intel.important,
|
||||
});
|
||||
}, [emails, onStats]);
|
||||
|
||||
function changeFilter(name, value) {
|
||||
setFilters((current) => ({ ...current, [name]: value }));
|
||||
setCursor(null);
|
||||
|
|
@ -121,7 +180,7 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
|||
}
|
||||
try {
|
||||
await setEmailStar(email.id, isStarred, token);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
|
||||
);
|
||||
|
|
@ -131,6 +190,79 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
|||
}
|
||||
}
|
||||
|
||||
async function markUnread(email) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||||
);
|
||||
try {
|
||||
await setEmailRead(email.id, false, token);
|
||||
} catch {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: email.is_read } : item),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmail(email) {
|
||||
setEmails((current) => current.filter((item) => item.id !== email.id));
|
||||
if (detail?.id === email.id) {
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
try {
|
||||
await trashEmail(email.id, token);
|
||||
} catch (error) {
|
||||
setAgentFocus(error.message);
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function hideForSession(email, message) {
|
||||
setHidden((current) => ({ ...current, [email.id]: true }));
|
||||
if (detail?.id === email.id) {
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
setAgentFocus(message);
|
||||
}
|
||||
|
||||
function summarize(email) {
|
||||
const subject = email.subject?.trim() || "(No subject)";
|
||||
const snippet = email.snippet || email.preview || email.body_text || "No preview available.";
|
||||
setAgentFocus(`${subject} — ${snippet}`);
|
||||
}
|
||||
|
||||
function runQuickAction(action) {
|
||||
const intel = inboxIntelligence(listedEmails);
|
||||
if (action === "summarize") {
|
||||
setAgentFocus(
|
||||
listedEmails.length
|
||||
? `${listedEmails.length} messages in ${category}. ${intel.actionItems} look like they need a reply, ${intel.meetings} mention a meeting, and ${intel.invoices} look like bills.`
|
||||
: "Nothing in this view to summarize yet.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === "draft") {
|
||||
const first = listedEmails.find((email) => email.is_read === false) || listedEmails[0];
|
||||
setAgentFocus(first
|
||||
? `Draft a reply to ${first.subject || "the latest message"} when you open compose.`
|
||||
: "No message is waiting for a reply.");
|
||||
return;
|
||||
}
|
||||
if (action === "attachments" || action === "actions" || action === "important" || action === "actionItems" || action === "meetings" || action === "invoices") {
|
||||
const labels = {
|
||||
attachments: "attachments",
|
||||
actions: "action items",
|
||||
actionItems: "action items",
|
||||
important: "important mail",
|
||||
meetings: "meetings",
|
||||
invoices: "invoices",
|
||||
};
|
||||
setCategory(action === "invoices" ? "Finance" : action === "important" ? "Primary" : category);
|
||||
setAgentFocus(`Showing signals for ${labels[action] || action} from the messages already loaded.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRead(isRead) {
|
||||
if (!detail) return;
|
||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||
|
|
@ -185,120 +317,168 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
|||
|
||||
const hasFilters =
|
||||
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
|
||||
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
|
||||
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
|
||||
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
|
||||
|
||||
return (
|
||||
<section className="mailbox-workspace">
|
||||
<div className="mailbox-main-column">
|
||||
<div className="dashboard-content-heading">
|
||||
<div><p>Personal workspace</p><h1>{title}</h1></div>
|
||||
<span className="dashboard-account-pill">Individual</span>
|
||||
</div>
|
||||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||||
<div className="mailbox-list-pane">
|
||||
<div className="mailbox-toolbar">
|
||||
<label>
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="search"
|
||||
value={filters.query}
|
||||
onChange={(event) => changeFilter("query", event.target.value)}
|
||||
placeholder={`Search ${title.toLowerCase()}`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={filtersOpen || hasFilters ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((open) => !open)}
|
||||
aria-expanded={filtersOpen}
|
||||
>
|
||||
<Filter size={16} /> <span>Filters</span>
|
||||
</button>
|
||||
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="mailbox-filters">
|
||||
<label>Read status
|
||||
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
|
||||
<option value="all">All messages</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="read">Read</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Sender
|
||||
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
|
||||
</label>
|
||||
<label>From
|
||||
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
|
||||
</label>
|
||||
<label>To
|
||||
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
|
||||
</label>
|
||||
<header className="workspace-header">
|
||||
<label className="workspace-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
id="workspace-search"
|
||||
type="search"
|
||||
value={filters.query}
|
||||
onChange={(event) => changeFilter("query", event.target.value)}
|
||||
placeholder="Search emails, people, or ask your agent..."
|
||||
/>
|
||||
<kbd>Ctrl K</kbd>
|
||||
</label>
|
||||
<button
|
||||
className={`workspace-filter${filtersOpen || hasFilters ? " active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((open) => !open)}
|
||||
aria-expanded={filtersOpen}
|
||||
aria-label="Advanced filters"
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
<button className="workspace-ask" type="button" onClick={() => runQuickAction("summarize")}>
|
||||
<Sparkles size={15} /> Ask Agent
|
||||
</button>
|
||||
<button type="button" className="workspace-icon" aria-label="Refresh" onClick={load}><RefreshCw size={16} /></button>
|
||||
<button type="button" className="workspace-icon" aria-label="Notifications"><Bell size={16} /></button>
|
||||
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
|
||||
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
|
||||
<button type="button" className="workspace-account" onClick={onLogout} title="Log out">
|
||||
<span>{initials || "AG"}</span>
|
||||
<div>
|
||||
<strong>{account?.first_name} {account?.last_name}</strong>
|
||||
<small>{account?.email}</small>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="mailbox-rows" aria-busy={status.loading}>
|
||||
{status.loading && <div className="mailbox-state">Loading messages…</div>}
|
||||
{status.error && (
|
||||
<div className="mailbox-state error" role="alert">
|
||||
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
|
||||
<div className="mailbox-stage">
|
||||
<div className="mailbox-main-column">
|
||||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||||
<div className="mailbox-list-pane">
|
||||
{mailbox === "inbox" && (
|
||||
<div className="mailbox-categories" role="tablist" aria-label="Inbox categories">
|
||||
{CATEGORIES.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === name}
|
||||
className={category === name ? "active" : ""}
|
||||
onClick={() => setCategory(name)}
|
||||
>
|
||||
{name}
|
||||
{name !== "All Mail" && counts[name] > 0 && <span>{counts[name]}</span>}
|
||||
</button>
|
||||
))}
|
||||
<small className="mailbox-range">
|
||||
{listedEmails.length ? `${rangeStart}–${rangeEnd}` : title}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mailbox !== "inbox" && (
|
||||
<div className="mailbox-categories">
|
||||
<button type="button" className="active">{title}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="mailbox-filters">
|
||||
<label>Read status
|
||||
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
|
||||
<option value="all">All messages</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="read">Read</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Sender
|
||||
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
|
||||
</label>
|
||||
<label>From
|
||||
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
|
||||
</label>
|
||||
<label>To
|
||||
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mailbox-rows" aria-busy={status.loading}>
|
||||
{status.loading && <div className="mailbox-state">Loading messages…</div>}
|
||||
{status.error && (
|
||||
<div className="mailbox-state error" role="alert">
|
||||
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && listedEmails.length === 0 && (
|
||||
<div className="mailbox-state empty">
|
||||
<span><Inbox size={22} /></span>
|
||||
<strong>No messages here</strong>
|
||||
<p>{hasFilters || filters.query || lens !== "all" ? "Try another category, label, or search." : `Your ${title.toLowerCase()} is empty.`}</p>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && listedEmails.map((email) => (
|
||||
<MailRow
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={selected === email.id}
|
||||
checked={Boolean(checked[email.id])}
|
||||
onOpen={openEmail}
|
||||
onToggleStar={toggleStar}
|
||||
onToggleCheck={(item) => setChecked((current) => ({ ...current, [item.id]: !current[item.id] }))}
|
||||
onTrash={removeEmail}
|
||||
onMarkUnread={markUnread}
|
||||
onArchive={(item) => hideForSession(item, "Archived in this view until you refresh.")}
|
||||
onSnooze={(item) => hideForSession(item, "Snoozed in this view until you refresh.")}
|
||||
onSummarize={summarize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(cursorHistory.length > 0 || nextCursor) && (
|
||||
<footer className="mailbox-pagination">
|
||||
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
|
||||
<span>Page {cursorHistory.length + 1}</span>
|
||||
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
loading={detailStatus.loading}
|
||||
error={detailStatus.error}
|
||||
busy={detailStatus.busy}
|
||||
mailbox={mailbox}
|
||||
onClose={() => { setSelected(null); setDetail(null); }}
|
||||
onToggleRead={toggleRead}
|
||||
onTrash={() => mutateSelected(trashEmail)}
|
||||
onRestore={() => mutateSelected(restoreEmail)}
|
||||
onDelete={deletePermanently}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && emails.length === 0 && (
|
||||
<div className="mailbox-state empty">
|
||||
<span><Inbox size={22} /></span>
|
||||
<strong>No messages here</strong>
|
||||
<p>{hasFilters || filters.query ? "Try changing your search or filters." : `Your ${title.toLowerCase()} is empty.`}</p>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && emails.map((email) => (
|
||||
<MailRow
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={selected === email.id}
|
||||
onOpen={openEmail}
|
||||
onToggleStar={toggleStar}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!status.loading && !status.error && emails.length > 0 && (
|
||||
<footer className="mailbox-list-footer">
|
||||
<p>
|
||||
{emails.length} message{emails.length === 1 ? "" : "s"} in this view
|
||||
{filters.query.trim() ? " · filtered" : ""}
|
||||
</p>
|
||||
<span>You're all caught up</span>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{(cursorHistory.length > 0 || nextCursor) && (
|
||||
<footer className="mailbox-pagination">
|
||||
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
|
||||
<span>Page {cursorHistory.length + 1}</span>
|
||||
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
loading={detailStatus.loading}
|
||||
error={detailStatus.error}
|
||||
busy={detailStatus.busy}
|
||||
mailbox={mailbox}
|
||||
onClose={() => { setSelected(null); setDetail(null); }}
|
||||
onToggleRead={toggleRead}
|
||||
onTrash={() => mutateSelected(trashEmail)}
|
||||
onRestore={() => mutateSelected(restoreEmail)}
|
||||
onDelete={deletePermanently}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="mailbox-rail">
|
||||
<AgAgentPanel
|
||||
account={account}
|
||||
emails={listedEmails}
|
||||
focus={agentFocus}
|
||||
onQuickAction={runQuickAction}
|
||||
/>
|
||||
<StickyNotesPanel compact />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<StickyNotesPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue