ui improvements
This commit is contained in:
parent
cce9a8c9ba
commit
78aac5a925
7 changed files with 769 additions and 138 deletions
110
frontend/src/components/AgAgentPanel.jsx
Normal file
110
frontend/src/components/AgAgentPanel.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
FileSearch,
|
||||||
|
ListChecks,
|
||||||
|
PenLine,
|
||||||
|
Receipt,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { inboxIntelligence } from "../utils/inboxSignals";
|
||||||
|
|
||||||
|
function greeting(name) {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const period = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
|
||||||
|
return `${period}, ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
key: "important",
|
||||||
|
label: "Important emails",
|
||||||
|
hint: "Need a look",
|
||||||
|
icon: Star,
|
||||||
|
tone: "rose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actionItems",
|
||||||
|
label: "Action items",
|
||||||
|
hint: "Need your response",
|
||||||
|
icon: ListChecks,
|
||||||
|
tone: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "meetings",
|
||||||
|
label: "Meetings mentioned",
|
||||||
|
hint: "In this view",
|
||||||
|
icon: Calendar,
|
||||||
|
tone: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invoices",
|
||||||
|
label: "Invoices & bills",
|
||||||
|
hint: "This week",
|
||||||
|
icon: Receipt,
|
||||||
|
tone: "amber",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AgAgentPanel({ account, emails, focus, onQuickAction }) {
|
||||||
|
const stats = inboxIntelligence(emails);
|
||||||
|
const firstName = account?.first_name || "there";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="agent-panel" aria-label="AG Agent">
|
||||||
|
<header className="agent-panel-head">
|
||||||
|
<span className="agent-panel-mark" aria-hidden="true"><Sparkles size={15} /></span>
|
||||||
|
<div>
|
||||||
|
<h2>AG Agent</h2>
|
||||||
|
<p>Your email co-pilot</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="agent-panel-hello">
|
||||||
|
<strong>{greeting(firstName)}</strong>
|
||||||
|
<span>Here's what needs your attention.</span>
|
||||||
|
{focus && <p>{focus}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-stat-grid">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
const count = stats[card.key] || 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.key}
|
||||||
|
type="button"
|
||||||
|
className={`agent-stat agent-stat-${card.tone}`}
|
||||||
|
onClick={() => onQuickAction(card.key)}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
<strong>{count}</strong>
|
||||||
|
<span>{card.label}</span>
|
||||||
|
<small>{card.key === "important" && stats.importantFromClients
|
||||||
|
? `${stats.importantFromClients} from clients`
|
||||||
|
: card.hint}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-quick">
|
||||||
|
<h3>Quick actions</h3>
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => onQuickAction("summarize")}>
|
||||||
|
<Sparkles size={14} /> Summarize inbox
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onQuickAction("draft")}>
|
||||||
|
<PenLine size={14} /> Draft replies
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onQuickAction("attachments")}>
|
||||||
|
<FileSearch size={14} /> Find attachments
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onQuickAction("actions")}>
|
||||||
|
<ListChecks size={14} /> Show action items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
ChevronDown,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
FileEdit,
|
||||||
Inbox,
|
Inbox,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
|
@ -11,11 +13,12 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Tags,
|
Tags,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { getCurrentAccount, getMyCompanyDomain } from "../api";
|
import { getCurrentAccount, getMyCompanyDomain } from "../api";
|
||||||
import Brand from "./Brand";
|
import Brand from "./Brand";
|
||||||
import CompanyDnsSetup from "./CompanyDnsSetup";
|
import CompanyDnsSetup from "./CompanyDnsSetup";
|
||||||
|
|
@ -23,12 +26,22 @@ import ComposeModal from "./ComposeModal";
|
||||||
import MailboxView from "./MailboxView";
|
import MailboxView from "./MailboxView";
|
||||||
|
|
||||||
const mailItems = [
|
const mailItems = [
|
||||||
[Inbox, "Inbox"],
|
[Inbox, "Inbox", "unread"],
|
||||||
[Send, "Outbox"],
|
[Star, "Starred", "starred"],
|
||||||
|
[Clock3, "Snoozed"],
|
||||||
|
[Send, "Sent"],
|
||||||
|
[FileEdit, "Drafts"],
|
||||||
[ShieldAlert, "Spam"],
|
[ShieldAlert, "Spam"],
|
||||||
[Trash2, "Trash"],
|
[Trash2, "Trash"],
|
||||||
[Clock3, "Scheduled"],
|
[Clock3, "Scheduled"],
|
||||||
[Settings, "Settings"],
|
];
|
||||||
|
|
||||||
|
const labels = [
|
||||||
|
["Important", "rose"],
|
||||||
|
["Projects", "violet"],
|
||||||
|
["Clients", "blue"],
|
||||||
|
["Finance", "amber"],
|
||||||
|
["Personal", "green"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const agentItems = [
|
const agentItems = [
|
||||||
|
|
@ -36,6 +49,7 @@ const agentItems = [
|
||||||
[ListFilter, "Filters"],
|
[ListFilter, "Filters"],
|
||||||
[Sparkles, "Summary"],
|
[Sparkles, "Summary"],
|
||||||
[Plug, "Integration"],
|
[Plug, "Integration"],
|
||||||
|
[Settings, "Settings"],
|
||||||
];
|
];
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
|
|
@ -43,20 +57,21 @@ function clearSession() {
|
||||||
sessionStorage.removeItem("agmail_account");
|
sessionStorage.removeItem("agmail_account");
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
|
function NavGroup({ icon: Icon, title, items, active, counts, onSelect }) {
|
||||||
return (
|
return (
|
||||||
<section className="dashboard-nav-group">
|
<section className="dashboard-nav-group">
|
||||||
<h2><Icon size={15} />{title}</h2>
|
<h2><Icon size={13} />{title}</h2>
|
||||||
<nav aria-label={title}>
|
<nav aria-label={title}>
|
||||||
{items.map(([ItemIcon, label]) => (
|
{items.map(([ItemIcon, label, countKey]) => (
|
||||||
<button
|
<button
|
||||||
className={active === label ? "active" : ""}
|
className={active === label ? "active" : ""}
|
||||||
key={label}
|
key={label}
|
||||||
onClick={() => onSelect(label)}
|
onClick={() => onSelect(label)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ItemIcon size={17} />
|
<ItemIcon size={16} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
{countKey && counts?.[countKey] > 0 && <em>{counts[countKey]}</em>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -75,6 +90,10 @@ export default function DashboardPage() {
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
||||||
|
const [labelsOpen, setLabelsOpen] = useState(true);
|
||||||
|
const [lens, setLens] = useState("all");
|
||||||
|
const [mailStats, setMailStats] = useState({ unread: 0, starred: 0, important: 0 });
|
||||||
|
const handleStats = useCallback((stats) => setMailStats(stats), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = sessionStorage.getItem("agmail_access_token");
|
const token = sessionStorage.getItem("agmail_access_token");
|
||||||
|
|
@ -110,7 +129,19 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(item) {
|
function selectItem(item) {
|
||||||
setActive(item);
|
if (item === "Starred") {
|
||||||
|
setActive("Inbox");
|
||||||
|
setLens("starred");
|
||||||
|
} else if (item === "Sent") {
|
||||||
|
setActive("Outbox");
|
||||||
|
setLens("all");
|
||||||
|
} else if (labels.some(([name]) => name === item)) {
|
||||||
|
setActive("Inbox");
|
||||||
|
setLens(item);
|
||||||
|
} else {
|
||||||
|
setActive(item);
|
||||||
|
setLens("all");
|
||||||
|
}
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +210,30 @@ export default function DashboardPage() {
|
||||||
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
||||||
<Mail size={17} />Compose
|
<Mail size={17} />Compose
|
||||||
</button>
|
</button>
|
||||||
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
|
<NavGroup icon={Mail} title="Mail" items={mailItems} active={lens === "starred" ? "Starred" : active === "Outbox" ? "Sent" : active} counts={mailStats} onSelect={selectItem} />
|
||||||
|
<section className="dashboard-labels">
|
||||||
|
<button type="button" className="dashboard-labels-toggle" onClick={() => setLabelsOpen((open) => !open)} aria-expanded={labelsOpen}>
|
||||||
|
<Tags size={16} />
|
||||||
|
<span>Labels</span>
|
||||||
|
<ChevronDown size={14} className={labelsOpen ? "open" : ""} />
|
||||||
|
</button>
|
||||||
|
{labelsOpen && (
|
||||||
|
<nav aria-label="Labels">
|
||||||
|
{labels.map(([name, tone]) => (
|
||||||
|
<button
|
||||||
|
className={lens === name ? "active" : ""}
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectItem(name)}
|
||||||
|
>
|
||||||
|
<i className={`label-dot label-dot-${tone}`} />
|
||||||
|
<span>{name}</span>
|
||||||
|
{name === "Important" && mailStats.important > 0 && <em>{mailStats.important}</em>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
|
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
|
||||||
<div className="dashboard-profile">
|
<div className="dashboard-profile">
|
||||||
<span>{account.first_name[0]}{account.last_name[0]}</span>
|
<span>{account.first_name[0]}{account.last_name[0]}</span>
|
||||||
|
|
@ -195,9 +249,38 @@ export default function DashboardPage() {
|
||||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
|
{active === "Inbox" && (
|
||||||
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
|
<MailboxView
|
||||||
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
|
mailbox="inbox"
|
||||||
|
title="Inbox"
|
||||||
|
refreshKey={mailRefreshKey}
|
||||||
|
account={account}
|
||||||
|
lens={lens}
|
||||||
|
onLogout={logout}
|
||||||
|
onOpenSettings={() => selectItem("Settings")}
|
||||||
|
onStats={handleStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{active === "Outbox" && (
|
||||||
|
<MailboxView
|
||||||
|
mailbox="sent"
|
||||||
|
title="Sent"
|
||||||
|
refreshKey={mailRefreshKey}
|
||||||
|
account={account}
|
||||||
|
onLogout={logout}
|
||||||
|
onOpenSettings={() => selectItem("Settings")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{active === "Trash" && (
|
||||||
|
<MailboxView
|
||||||
|
mailbox="trash"
|
||||||
|
title="Trash"
|
||||||
|
refreshKey={mailRefreshKey}
|
||||||
|
account={account}
|
||||||
|
onLogout={logout}
|
||||||
|
onOpenSettings={() => selectItem("Settings")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||||
<section className="dashboard-empty">
|
<section className="dashboard-empty">
|
||||||
<span><Sparkles size={24} /></span>
|
<span><Sparkles size={24} /></span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { MailOpen, Star } from "lucide-react";
|
import {
|
||||||
|
Archive,
|
||||||
|
Clock3,
|
||||||
|
MailOpen,
|
||||||
|
Paperclip,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import SenderAvatar from "./SenderAvatar";
|
import SenderAvatar from "./SenderAvatar";
|
||||||
import { senderDisplayName } from "../utils/senderAvatar";
|
import { senderDisplayName } from "../utils/senderAvatar";
|
||||||
|
import { attachmentCount, emailLabels } from "../utils/inboxSignals";
|
||||||
|
|
||||||
function formatTimestamp(value) {
|
function formatTimestamp(value) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
@ -21,7 +30,19 @@ function formatTimestamp(value) {
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
export default function MailRow({
|
||||||
|
email,
|
||||||
|
selected,
|
||||||
|
checked,
|
||||||
|
onOpen,
|
||||||
|
onToggleStar,
|
||||||
|
onToggleCheck,
|
||||||
|
onTrash,
|
||||||
|
onMarkUnread,
|
||||||
|
onArchive,
|
||||||
|
onSnooze,
|
||||||
|
onSummarize,
|
||||||
|
}) {
|
||||||
const unread = email.is_read === false;
|
const unread = email.is_read === false;
|
||||||
const starred = email.is_starred === true;
|
const starred = email.is_starred === true;
|
||||||
const subject = email.subject?.trim() || "(No subject)";
|
const subject = email.subject?.trim() || "(No subject)";
|
||||||
|
|
@ -29,6 +50,8 @@ export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
||||||
const timestamp =
|
const timestamp =
|
||||||
email.received_at || email.sent_at || email.created_at || email.updated_at;
|
email.received_at || email.sent_at || email.created_at || email.updated_at;
|
||||||
const senderName = senderDisplayName(email);
|
const senderName = senderDisplayName(email);
|
||||||
|
const labels = emailLabels(email);
|
||||||
|
const files = attachmentCount(email);
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
|
@ -37,9 +60,9 @@ export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStarClick(event) {
|
function stop(event, action) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onToggleStar?.(email, !starred);
|
action?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,25 +74,48 @@ export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
|
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
|
||||||
>
|
>
|
||||||
<span className="mailbox-read-dot" aria-hidden="true">
|
<input
|
||||||
{unread ? <i /> : <MailOpen size={14} />}
|
className="mailbox-check"
|
||||||
</span>
|
type="checkbox"
|
||||||
<SenderAvatar email={email} className="mailbox-avatar" />
|
checked={checked}
|
||||||
<span className="mailbox-sender">{senderName}</span>
|
aria-label={`Select message from ${senderName}`}
|
||||||
<span className="mailbox-summary">
|
onClick={(event) => event.stopPropagation()}
|
||||||
<strong>{subject}</strong>
|
onChange={() => onToggleCheck?.(email)}
|
||||||
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
|
/>
|
||||||
</span>
|
|
||||||
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`mailbox-star${starred ? " active" : ""}`}
|
className={`mailbox-star${starred ? " active" : ""}`}
|
||||||
onClick={handleStarClick}
|
onClick={(event) => stop(event, () => onToggleStar?.(email, !starred))}
|
||||||
aria-label={starred ? "Unstar message" : "Star message"}
|
aria-label={starred ? "Unstar message" : "Star message"}
|
||||||
aria-pressed={starred}
|
aria-pressed={starred}
|
||||||
>
|
>
|
||||||
<Star size={15} fill={starred ? "currentColor" : "none"} />
|
<Star size={15} fill={starred ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
|
<SenderAvatar email={email} className="mailbox-avatar" />
|
||||||
|
<span className="mailbox-sender">
|
||||||
|
{unread && <i className="mailbox-unread-pip" aria-hidden="true" />}
|
||||||
|
{senderName}
|
||||||
|
</span>
|
||||||
|
<span className="mailbox-summary">
|
||||||
|
<strong>{subject}</strong>
|
||||||
|
{snippet && <span>{snippet}</span>}
|
||||||
|
{labels.map((label) => (
|
||||||
|
<em key={label.name} className={`mail-label mail-label-${label.tone}`}>{label.name}</em>
|
||||||
|
))}
|
||||||
|
{files > 0 && (
|
||||||
|
<em className="mail-attachment" title={`${files} attachment${files === 1 ? "" : "s"}`}>
|
||||||
|
<Paperclip size={12} /> {files > 1 ? files : ""}
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
|
||||||
|
<div className="mailbox-hover-actions">
|
||||||
|
<button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}><Archive size={14} /></button>
|
||||||
|
<button type="button" title="Snooze" aria-label="Snooze" onClick={(event) => stop(event, () => onSnooze?.(email))}><Clock3 size={14} /></button>
|
||||||
|
<button type="button" title="Mark unread" aria-label="Mark unread" onClick={(event) => stop(event, () => onMarkUnread?.(email))}><MailOpen size={14} /></button>
|
||||||
|
<button type="button" title="Delete" aria-label="Delete" onClick={(event) => stop(event, () => onTrash?.(email))}><Trash2 size={14} /></button>
|
||||||
|
<button type="button" title="Summarize" aria-label="Summarize" onClick={(event) => stop(event, () => onSummarize?.(email))}><Sparkles size={14} /></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
CircleHelp,
|
||||||
Filter,
|
Filter,
|
||||||
Inbox,
|
Inbox,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getEmail,
|
getEmail,
|
||||||
listEmails,
|
listEmails,
|
||||||
|
|
@ -16,11 +21,19 @@ import {
|
||||||
setEmailStar,
|
setEmailStar,
|
||||||
trashEmail,
|
trashEmail,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import AgAgentPanel from "./AgAgentPanel";
|
||||||
import MailDetail from "./MailDetail";
|
import MailDetail from "./MailDetail";
|
||||||
import MailRow from "./MailRow";
|
import MailRow from "./MailRow";
|
||||||
import StickyNotesPanel from "./StickyNotesPanel";
|
import StickyNotesPanel from "./StickyNotesPanel";
|
||||||
|
import {
|
||||||
|
categoryCounts,
|
||||||
|
emailCategory,
|
||||||
|
inboxIntelligence,
|
||||||
|
matchesLens,
|
||||||
|
} from "../utils/inboxSignals";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
const CATEGORIES = ["Primary", "Updates", "Promotions", "Social", "Finance", "All Mail"];
|
||||||
|
|
||||||
function resultPage(result) {
|
function resultPage(result) {
|
||||||
if (Array.isArray(result)) return { items: result, nextCursor: null };
|
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 token = sessionStorage.getItem("agmail_access_token");
|
||||||
const [emails, setEmails] = useState([]);
|
const [emails, setEmails] = useState([]);
|
||||||
const [nextCursor, setNextCursor] = useState(null);
|
const [nextCursor, setNextCursor] = useState(null);
|
||||||
const [cursor, setCursor] = useState(null);
|
const [cursor, setCursor] = useState(null);
|
||||||
const [cursorHistory, setCursorHistory] = useState([]);
|
const [cursorHistory, setCursorHistory] = useState([]);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [checked, setChecked] = useState({});
|
||||||
|
const [hidden, setHidden] = useState({});
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [status, setStatus] = useState({ loading: true, error: "" });
|
const [status, setStatus] = useState({ loading: true, error: "" });
|
||||||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [category, setCategory] = useState("Primary");
|
||||||
|
const [agentFocus, setAgentFocus] = useState("");
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
query: "",
|
query: "",
|
||||||
read: "all",
|
read: "all",
|
||||||
|
|
@ -79,6 +105,39 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [load, refreshKey]);
|
}, [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) {
|
function changeFilter(name, value) {
|
||||||
setFilters((current) => ({ ...current, [name]: value }));
|
setFilters((current) => ({ ...current, [name]: value }));
|
||||||
setCursor(null);
|
setCursor(null);
|
||||||
|
|
@ -121,7 +180,7 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await setEmailStar(email.id, isStarred, token);
|
await setEmailStar(email.id, isStarred, token);
|
||||||
} catch (error) {
|
} catch {
|
||||||
setEmails((current) =>
|
setEmails((current) =>
|
||||||
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
|
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) {
|
async function toggleRead(isRead) {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||||
|
|
@ -185,120 +317,168 @@ export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
|
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 (
|
return (
|
||||||
<section className="mailbox-workspace">
|
<section className="mailbox-workspace">
|
||||||
<div className="mailbox-main-column">
|
<header className="workspace-header">
|
||||||
<div className="dashboard-content-heading">
|
<label className="workspace-search">
|
||||||
<div><p>Personal workspace</p><h1>{title}</h1></div>
|
<Search size={16} />
|
||||||
<span className="dashboard-account-pill">Individual</span>
|
<input
|
||||||
</div>
|
id="workspace-search"
|
||||||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
type="search"
|
||||||
<div className="mailbox-list-pane">
|
value={filters.query}
|
||||||
<div className="mailbox-toolbar">
|
onChange={(event) => changeFilter("query", event.target.value)}
|
||||||
<label>
|
placeholder="Search emails, people, or ask your agent..."
|
||||||
<Search size={16} />
|
/>
|
||||||
<input
|
<kbd>Ctrl K</kbd>
|
||||||
type="search"
|
</label>
|
||||||
value={filters.query}
|
<button
|
||||||
onChange={(event) => changeFilter("query", event.target.value)}
|
className={`workspace-filter${filtersOpen || hasFilters ? " active" : ""}`}
|
||||||
placeholder={`Search ${title.toLowerCase()}`}
|
type="button"
|
||||||
/>
|
onClick={() => setFiltersOpen((open) => !open)}
|
||||||
</label>
|
aria-expanded={filtersOpen}
|
||||||
<button
|
aria-label="Advanced filters"
|
||||||
className={filtersOpen || hasFilters ? "active" : ""}
|
>
|
||||||
type="button"
|
<Filter size={16} />
|
||||||
onClick={() => setFiltersOpen((open) => !open)}
|
</button>
|
||||||
aria-expanded={filtersOpen}
|
<button className="workspace-ask" type="button" onClick={() => runQuickAction("summarize")}>
|
||||||
>
|
<Sparkles size={15} /> Ask Agent
|
||||||
<Filter size={16} /> <span>Filters</span>
|
</button>
|
||||||
</button>
|
<button type="button" className="workspace-icon" aria-label="Refresh" onClick={load}><RefreshCw size={16} /></button>
|
||||||
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
|
<button type="button" className="workspace-icon" aria-label="Notifications"><Bell size={16} /></button>
|
||||||
<RefreshCw size={16} />
|
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
|
||||||
</button>
|
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
|
||||||
</div>
|
<button type="button" className="workspace-account" onClick={onLogout} title="Log out">
|
||||||
|
<span>{initials || "AG"}</span>
|
||||||
{filtersOpen && (
|
<div>
|
||||||
<div className="mailbox-filters">
|
<strong>{account?.first_name} {account?.last_name}</strong>
|
||||||
<label>Read status
|
<small>{account?.email}</small>
|
||||||
<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>
|
||||||
)}
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="mailbox-rows" aria-busy={status.loading}>
|
<div className="mailbox-stage">
|
||||||
{status.loading && <div className="mailbox-state">Loading messages…</div>}
|
<div className="mailbox-main-column">
|
||||||
{status.error && (
|
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||||||
<div className="mailbox-state error" role="alert">
|
<div className="mailbox-list-pane">
|
||||||
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
|
{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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<aside className="mailbox-rail">
|
||||||
|
<AgAgentPanel
|
||||||
|
account={account}
|
||||||
|
emails={listedEmails}
|
||||||
|
focus={agentFocus}
|
||||||
|
onQuickAction={runQuickAction}
|
||||||
|
/>
|
||||||
|
<StickyNotesPanel compact />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StickyNotesPanel />
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StickyNotesPanel() {
|
export default function StickyNotesPanel({ compact = false }) {
|
||||||
const token = sessionStorage.getItem("agmail_access_token");
|
const token = sessionStorage.getItem("agmail_access_token");
|
||||||
const [notes, setNotes] = useState([]);
|
const [notes, setNotes] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -178,7 +178,7 @@ export default function StickyNotesPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
|
<aside className={`mailbox-notes-pane${compact ? " compact" : ""}`} aria-label="Sticky notes">
|
||||||
<header className="sticky-notes-header">
|
<header className="sticky-notes-header">
|
||||||
<div className="sticky-notes-title">
|
<div className="sticky-notes-title">
|
||||||
<Pin size={15} />
|
<Pin size={15} />
|
||||||
|
|
|
||||||
|
|
@ -450,4 +450,127 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.dns-value-cell { display:flex; align-items:flex-start; gap:8px; }
|
.dns-value-cell { display:flex; align-items:flex-start; gap:8px; }
|
||||||
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
|
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
|
||||||
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
|
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
|
||||||
|
.dashboard { grid-template-columns:248px minmax(0,1fr); background:#f4f3f1; }
|
||||||
|
.dashboard-sidebar { padding:22px 14px 16px; overflow:auto; border-right:0; color:#d7d3e2; background:linear-gradient(180deg,#1c1730 0%,#161226 55%,#221833 100%); box-shadow:none; }
|
||||||
|
.dashboard-brand-row>button { color:#c8c3d4; }
|
||||||
|
.dashboard-sidebar .brand { color:#fff; font-size:18px; }
|
||||||
|
.dashboard-sidebar .brand-mark { background:#fff; }
|
||||||
|
.dashboard-sidebar .robot-eyes i { background:#1c1730; }
|
||||||
|
.dashboard-sidebar .robot-smile { border-color:#1c1730; }
|
||||||
|
.dashboard-compose { margin:22px 4px 18px; color:#fff; background:linear-gradient(180deg,#ff8d72,#ff6a52); box-shadow:0 10px 22px #ff6a5240; border-radius:12px; }
|
||||||
|
.dashboard-compose:hover { background:linear-gradient(180deg,#ff9a82,#ff735c); }
|
||||||
|
.dashboard-nav-group h2 { color:#8d879c; }
|
||||||
|
.dashboard-nav-group button { min-height:36px; color:#c8c3d4; border-radius:10px; font-size:13px; }
|
||||||
|
.dashboard-nav-group button em,.dashboard-labels button em { margin-left:auto; min-width:18px; height:18px; padding:0 6px; display:grid; place-items:center; border-radius:99px; background:#ff6a52; color:#fff; font-size:10px; font-style:normal; font-weight:700; }
|
||||||
|
.dashboard-nav-group button:hover { background:#ffffff12; color:#fff; }
|
||||||
|
.dashboard-nav-group button.active { background:#ffffff14; color:#fff; font-weight:600; box-shadow:none; }
|
||||||
|
.dashboard-labels { margin:2px 0 18px; }
|
||||||
|
.dashboard-labels-toggle,.dashboard-labels button { width:100%; min-height:36px; padding:0 11px; display:flex; align-items:center; gap:11px; border:0; border-radius:10px; background:transparent; color:#c8c3d4; font-size:13px; text-align:left; }
|
||||||
|
.dashboard-labels-toggle svg:last-child { margin-left:auto; transition:transform .15s ease; }
|
||||||
|
.dashboard-labels-toggle svg.open { transform:rotate(180deg); }
|
||||||
|
.dashboard-labels nav { display:grid; gap:2px; margin-top:2px; }
|
||||||
|
.dashboard-labels button:hover,.dashboard-labels button.active { background:#ffffff12; color:#fff; }
|
||||||
|
.label-dot { width:8px; height:8px; border-radius:50%; }
|
||||||
|
.label-dot-rose { background:#ff8b78; }
|
||||||
|
.label-dot-violet { background:#a78bfa; }
|
||||||
|
.label-dot-blue { background:#7eb6ff; }
|
||||||
|
.label-dot-amber { background:#f0c14a; }
|
||||||
|
.label-dot-green { background:#7dcea0; }
|
||||||
|
.dashboard-profile { border-top-color:#ffffff14; }
|
||||||
|
.dashboard-profile>span { background:#ff8b78; color:#2a2038; }
|
||||||
|
.dashboard-profile strong { color:#fff; }
|
||||||
|
.dashboard-profile small,.dashboard-profile button { color:#a39eb2; }
|
||||||
|
.dashboard-profile button:hover { background:#ffffff12; color:#fff; }
|
||||||
|
.dashboard-content { padding:14px 16px 16px; background:#f7f6f4; }
|
||||||
|
.dashboard-content-heading h1 { font-size:clamp(28px,3vw,36px); letter-spacing:-1.4px; }
|
||||||
|
.mailbox-workspace { min-height:calc(100vh - 28px); display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.workspace-header { min-height:58px; display:flex; align-items:center; gap:8px; }
|
||||||
|
.workspace-search { height:42px; min-width:0; flex:1; padding:0 8px 0 14px; display:flex; align-items:center; gap:8px; border:1px solid #e6e1dc; border-radius:14px; background:#fff; color:#9a958f; box-shadow:0 1px 2px #08082108; }
|
||||||
|
.workspace-search input { min-width:0; width:100%; border:0; outline:0; background:transparent; color:#241f2b; font-size:13px; }
|
||||||
|
.workspace-search kbd { padding:3px 7px; border:1px solid #eee8e3; border-radius:7px; background:#faf8f6; color:#8d8792; font:600 10px "DM Sans"; white-space:nowrap; }
|
||||||
|
.workspace-filter,.workspace-icon { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:12px; background:transparent; color:#6f6a74; }
|
||||||
|
.workspace-filter.active,.workspace-icon:hover,.workspace-filter:hover { background:#fff; color:#241f2b; }
|
||||||
|
.workspace-ask { height:38px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #ffd3c8; border-radius:12px; background:#fff7f4; color:#d26552; font-size:13px; font-weight:700; white-space:nowrap; }
|
||||||
|
.workspace-ask:hover { background:#fff1eb; }
|
||||||
|
.workspace-account { margin-left:4px; padding:4px 8px 4px 4px; display:flex; align-items:center; gap:8px; border:0; border-radius:14px; background:transparent; color:#6d6872; }
|
||||||
|
.workspace-account:hover { background:#fff; }
|
||||||
|
.workspace-account>span { width:34px; height:34px; display:grid; place-items:center; border-radius:50%; background:#6d4aff; color:#fff; font-size:11px; font-weight:700; }
|
||||||
|
.workspace-account div { min-width:0; display:grid; text-align:left; }
|
||||||
|
.workspace-account strong,.workspace-account small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.workspace-account strong { color:#241f2b; font-size:12px; }
|
||||||
|
.workspace-account small { color:#8d8792; font-size:10px; max-width:150px; }
|
||||||
|
.mailbox-stage { min-height:0; flex:1; display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:14px; align-items:start; }
|
||||||
|
.mailbox-shell { min-height:calc(100vh - 110px); margin-top:0; border-radius:16px; box-shadow:0 1px 2px #08082106,0 10px 30px #08082108; }
|
||||||
|
.mailbox-list-pane { min-height:calc(100vh - 110px); }
|
||||||
|
.mailbox-categories { min-height:48px; padding:0 8px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #efeae6; overflow:auto; }
|
||||||
|
.mailbox-categories button { height:48px; padding:0 12px; position:relative; display:inline-flex; align-items:center; gap:7px; border:0; background:transparent; color:#6e6974; font-size:13px; font-weight:600; white-space:nowrap; }
|
||||||
|
.mailbox-categories button span { min-width:16px; height:16px; padding:0 5px; display:grid; place-items:center; border-radius:99px; background:#ff6a52; color:#fff; font-size:10px; }
|
||||||
|
.mailbox-categories button.active { color:#e15b45; }
|
||||||
|
.mailbox-categories button.active:after { content:""; position:absolute; left:12px; right:12px; bottom:0; height:2px; border-radius:2px; background:#ff6a52; }
|
||||||
|
.mailbox-range { margin-left:auto; padding-right:10px; color:#9a948e; font-size:11px; white-space:nowrap; }
|
||||||
|
.mailbox-row { min-height:52px; padding:6px 12px; grid-template-columns:18px 22px 34px minmax(120px,180px) minmax(0,1fr) auto; gap:8px 10px; border-left:0; font-size:13px; }
|
||||||
|
.mailbox-row.unread { background:#fff; }
|
||||||
|
.mailbox-row:not(.unread) { background:#fbfaf8; }
|
||||||
|
.mailbox-row:not(.unread) .mailbox-sender,.mailbox-row:not(.unread) .mailbox-summary strong { font-weight:500; color:#5c5764; }
|
||||||
|
.mailbox-check { width:15px; height:15px; margin:0; accent-color:#ff6a52; }
|
||||||
|
.mailbox-unread-pip { width:7px; height:7px; margin-right:6px; display:inline-block; border-radius:50%; background:#ff6a52; vertical-align:middle; }
|
||||||
|
.mailbox-sender { display:flex; align-items:center; font-size:13px; }
|
||||||
|
.mailbox-summary { gap:8px; align-items:center; }
|
||||||
|
.mailbox-summary span { color:#8a847e; }
|
||||||
|
.mail-label,.mail-attachment { padding:2px 7px; border-radius:99px; font-size:10px; font-style:normal; font-weight:700; white-space:nowrap; }
|
||||||
|
.mail-label-rose { background:#ffe8e3; color:#c45a48; }
|
||||||
|
.mail-label-violet { background:#efe9ff; color:#6d54b8; }
|
||||||
|
.mail-label-blue { background:#e7f1ff; color:#3d6eae; }
|
||||||
|
.mail-label-amber { background:#fff4d8; color:#9a7420; }
|
||||||
|
.mail-label-green { background:#e7f6ee; color:#2f7a52; }
|
||||||
|
.mail-attachment { display:inline-flex; align-items:center; gap:3px; background:#f3f1ef; color:#6d6872; }
|
||||||
|
.mailbox-hover-actions { display:none; align-items:center; gap:2px; }
|
||||||
|
.mailbox-row:hover .mailbox-hover-actions,.mailbox-row:focus-within .mailbox-hover-actions { display:flex; }
|
||||||
|
.mailbox-row:hover time,.mailbox-row:focus-within time { display:none; }
|
||||||
|
.mailbox-hover-actions button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:8px; background:#fff; color:#6d6872; box-shadow:0 1px 2px #08082110; }
|
||||||
|
.mailbox-hover-actions button:hover { color:#e15b45; background:#fff4f1; }
|
||||||
|
.mailbox-star { width:22px; height:22px; }
|
||||||
|
.sender-avatar.mailbox-avatar { width:32px; height:32px; font-size:11px; }
|
||||||
|
.mailbox-rail { min-width:0; display:flex; flex-direction:column; gap:12px; max-height:calc(100vh - 96px); position:sticky; top:14px; }
|
||||||
|
.agent-panel { padding:16px; border:1px solid #ece7e2; border-radius:16px; background:#fff; box-shadow:0 8px 24px #08082108; }
|
||||||
|
.agent-panel-head { display:flex; align-items:center; gap:10px; }
|
||||||
|
.agent-panel-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:10px; background:#fff1eb; color:#e15b45; }
|
||||||
|
.agent-panel-head h2 { margin:0; font:700 15px "Manrope"; letter-spacing:-.3px; }
|
||||||
|
.agent-panel-head p { margin:1px 0 0; color:#8d8792; font-size:11px; }
|
||||||
|
.agent-panel-hello { margin:14px 0 12px; display:grid; gap:3px; }
|
||||||
|
.agent-panel-hello strong { font-size:15px; letter-spacing:-.2px; }
|
||||||
|
.agent-panel-hello span,.agent-panel-hello p { margin:0; color:#7a746e; font-size:12px; line-height:1.45; }
|
||||||
|
.agent-stat-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.agent-stat { min-height:92px; padding:10px; display:grid; align-content:start; gap:2px; border:0; border-radius:12px; text-align:left; }
|
||||||
|
.agent-stat strong { font:700 20px "Manrope"; letter-spacing:-.6px; color:#241f2b; }
|
||||||
|
.agent-stat span { color:#3d3944; font-size:11px; font-weight:700; }
|
||||||
|
.agent-stat small { color:#7d7872; font-size:10px; }
|
||||||
|
.agent-stat-rose { background:#fff1ee; color:#e15b45; }
|
||||||
|
.agent-stat-blue { background:#eef4ff; color:#4d74c4; }
|
||||||
|
.agent-stat-green { background:#eef8f2; color:#3d8a5d; }
|
||||||
|
.agent-stat-amber { background:#fff8ea; color:#b7892d; }
|
||||||
|
.agent-quick { margin-top:14px; }
|
||||||
|
.agent-quick h3 { margin:0 0 8px; color:#8d8792; font-size:11px; letter-spacing:.4px; text-transform:uppercase; }
|
||||||
|
.agent-quick div { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.agent-quick button { min-height:36px; padding:0 8px; display:flex; align-items:center; justify-content:center; gap:6px; border:1px solid #eee8e3; border-radius:10px; background:#fff; color:#4d4852; font-size:11px; font-weight:600; }
|
||||||
|
.agent-quick button:hover { border-color:#ffd3c8; color:#e15b45; background:#fff8f5; }
|
||||||
|
.mailbox-notes-pane.compact { min-height:0; max-height:240px; border-radius:16px; }
|
||||||
|
.mailbox-notes-pane.compact .sticky-notes-header { min-height:44px; padding:8px 12px; }
|
||||||
|
.mailbox-notes-pane.compact .sticky-notes-body { padding:10px; gap:8px; }
|
||||||
|
.mailbox-notes-pane.compact .sticky-note { height:92px; min-height:92px; padding:12px 12px 8px; }
|
||||||
|
.mailbox-notes-pane.compact .sticky-note-add { min-height:64px; }
|
||||||
|
@media(max-width:1100px) {
|
||||||
|
.workspace-account div,.workspace-account svg { display:none; }
|
||||||
|
.mailbox-stage { grid-template-columns:minmax(0,1fr); }
|
||||||
|
.mailbox-rail { position:static; max-height:none; }
|
||||||
|
}
|
||||||
|
@media(max-width:900px) {
|
||||||
|
.dashboard-sidebar { background:linear-gradient(180deg,#1c1730,#161226); }
|
||||||
|
.mailbox-notes-pane { display:flex; }
|
||||||
|
.mailbox-row { grid-template-columns:16px 20px 32px minmax(0,1fr) auto; }
|
||||||
|
.mailbox-summary { grid-column:3/5; }
|
||||||
|
.mailbox-sender { grid-column:3; }
|
||||||
|
.mailbox-hover-actions { display:none!important; }
|
||||||
|
.mailbox-row:hover time { display:block; }
|
||||||
|
}
|
||||||
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; transition:none!important; } }
|
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; transition:none!important; } }
|
||||||
|
|
|
||||||
89
frontend/src/utils/inboxSignals.js
Normal file
89
frontend/src/utils/inboxSignals.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
const CATEGORY_ORDER = ["Primary", "Updates", "Promotions", "Social", "Finance"];
|
||||||
|
|
||||||
|
export function emailSearchText(email) {
|
||||||
|
const sender = typeof email.sender === "string"
|
||||||
|
? email.sender
|
||||||
|
: email.sender?.name || email.sender?.email || email.sender?.address || "";
|
||||||
|
return [
|
||||||
|
sender,
|
||||||
|
email.subject,
|
||||||
|
email.snippet,
|
||||||
|
email.preview,
|
||||||
|
email.body_text,
|
||||||
|
].filter(Boolean).join(" ").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailCategory(email) {
|
||||||
|
const text = emailSearchText(email);
|
||||||
|
if (/invoice|bill|payment|receipt|finance|bank|transfer|otp|wallet/.test(text)) return "Finance";
|
||||||
|
if (/linkedin|twitter|facebook|instagram|connection request|social/.test(text)) return "Social";
|
||||||
|
if (/unsubscribe|promo|offer|sale|newsletter|deal|discount/.test(text)) return "Promotions";
|
||||||
|
if (/notification|update|alert|noreply|no-reply|provisioned|activated|console|security/.test(text)) return "Updates";
|
||||||
|
return "Primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailLabels(email) {
|
||||||
|
const text = emailSearchText(email);
|
||||||
|
const labels = [];
|
||||||
|
if (email.is_starred || /urgent|important|asap|priority/.test(text)) {
|
||||||
|
labels.push({ name: "Important", tone: "rose" });
|
||||||
|
}
|
||||||
|
if (/project|proposal|website|development|sow|contract/.test(text)) {
|
||||||
|
labels.push({ name: "Projects", tone: "violet" });
|
||||||
|
}
|
||||||
|
if (/client|customer|internship|candidate/.test(text)) {
|
||||||
|
labels.push({ name: "Clients", tone: "blue" });
|
||||||
|
}
|
||||||
|
if (/invoice|bill|payment|finance|receipt/.test(text)) {
|
||||||
|
labels.push({ name: "Finance", tone: "amber" });
|
||||||
|
}
|
||||||
|
if (/personal|family/.test(text)) {
|
||||||
|
labels.push({ name: "Personal", tone: "green" });
|
||||||
|
}
|
||||||
|
return labels.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesLens(email, lens) {
|
||||||
|
if (!lens || lens === "all") return true;
|
||||||
|
if (lens === "starred") return email.is_starred === true;
|
||||||
|
return emailLabels(email).some((label) => label.name === lens);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryCounts(emails) {
|
||||||
|
const counts = Object.fromEntries(CATEGORY_ORDER.map((name) => [name, 0]));
|
||||||
|
emails.forEach((email) => {
|
||||||
|
if (email.is_read === false) counts[emailCategory(email)] += 1;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inboxIntelligence(emails) {
|
||||||
|
const unread = emails.filter((email) => email.is_read === false);
|
||||||
|
const important = emails.filter((email) =>
|
||||||
|
email.is_starred || emailLabels(email).some((label) => label.name === "Important") || email.is_read === false,
|
||||||
|
);
|
||||||
|
const actionItems = emails.filter((email) =>
|
||||||
|
/please|action|reply|review|attached|confirm|need/.test(emailSearchText(email)),
|
||||||
|
);
|
||||||
|
const meetings = emails.filter((email) =>
|
||||||
|
/meet|meeting|calendar|schedule|call|zoom|interview/.test(emailSearchText(email)),
|
||||||
|
);
|
||||||
|
const invoices = emails.filter((email) =>
|
||||||
|
/invoice|bill|payment|receipt/.test(emailSearchText(email)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
unread: unread.length,
|
||||||
|
important: important.length,
|
||||||
|
actionItems: actionItems.length,
|
||||||
|
meetings: meetings.length,
|
||||||
|
invoices: invoices.length,
|
||||||
|
importantFromClients: important.filter((email) =>
|
||||||
|
emailLabels(email).some((label) => label.name === "Clients"),
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentCount(email) {
|
||||||
|
if (Array.isArray(email.attachments)) return email.attachments.length;
|
||||||
|
return Number(email.attachment_count || 0);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue