first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
27
frontend/src/components/AuthLayout.jsx
Normal file
27
frontend/src/components/AuthLayout.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ArrowLeft, Bot, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
export default function AuthLayout({ children, eyebrow, title, description }) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<nav className="auth-nav">
|
||||
<Brand />
|
||||
<a className="back-link" href="/"><ArrowLeft size={15} /> Back home</a>
|
||||
</nav>
|
||||
<div className="auth-orb auth-orb-one" />
|
||||
<div className="auth-orb auth-orb-two" />
|
||||
<section className="auth-shell">
|
||||
<div className="auth-story">
|
||||
<div className="eyebrow"><Sparkles size={13} /> {eyebrow}</div>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<div className="auth-points">
|
||||
<span><Bot size={17} /> Ready for your AI agents</span>
|
||||
<span><ShieldCheck size={17} /> Private by design</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-card">{children}</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/Brand.jsx
Normal file
11
frontend/src/components/Brand.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function Brand({ compact = false }) {
|
||||
return (
|
||||
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<span className="robot-eyes"><i /><i /></span>
|
||||
<span className="robot-smile" />
|
||||
</span>
|
||||
{!compact && <span>agmail</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/CompanyDnsSetup.jsx
Normal file
139
frontend/src/components/CompanyDnsSetup.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { verifyCompanyDns } from "../api";
|
||||
import Brand from "./Brand";
|
||||
|
||||
function DnsRecordTable({ records }) {
|
||||
async function copyValue(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
/* clipboard may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dns-table-wrap">
|
||||
<table className="dns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Host</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
|
||||
const displayValue = record.record_type === "MX"
|
||||
? `10 ${record.value}`
|
||||
: record.value;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>{record.record_type}</td>
|
||||
<td><code>{record.host}</code></td>
|
||||
<td>
|
||||
<div className="dns-value-cell">
|
||||
<code>{displayValue}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="dns-copy"
|
||||
aria-label="Copy value"
|
||||
onClick={() => copyValue(displayValue)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{record.ttl}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
const [checks, setChecks] = useState(null);
|
||||
|
||||
async function handleVerify() {
|
||||
setStatus({ loading: true, error: "" });
|
||||
setChecks(null);
|
||||
try {
|
||||
const result = await verifyCompanyDns(token);
|
||||
setChecks(result.checks);
|
||||
if (result.verified) {
|
||||
setStatus({ loading: false, error: "" });
|
||||
onComplete(result);
|
||||
return;
|
||||
}
|
||||
setStatus({
|
||||
loading: false,
|
||||
error: "Some DNS records are missing or still propagating. Update your registrar and try again.",
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="company-dns-setup">
|
||||
<header className="company-dns-header">
|
||||
<Brand />
|
||||
<button type="button" onClick={onLogout}>Log out</button>
|
||||
</header>
|
||||
|
||||
<section className="company-dns-card">
|
||||
<p className="eyebrow">Company setup</p>
|
||||
<h1>Connect <em>{domain.domain_name}</em></h1>
|
||||
<p className="company-dns-lead">
|
||||
Add these DNS records at your domain registrar for <strong>{account.email}</strong>.
|
||||
Mail uses shared hosting at <strong>{domain.mail_host}</strong>, like Zoho or Namecheap Private Email.
|
||||
</p>
|
||||
|
||||
<DnsRecordTable records={domain.dns_records} />
|
||||
|
||||
<ul className="dns-notes">
|
||||
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
|
||||
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:<mail-server-ip> ~all</code>).</li>
|
||||
<li>DNS changes can take a few minutes to propagate.</li>
|
||||
</ul>
|
||||
|
||||
{checks && checks.length > 0 && (
|
||||
<div className="company-dns-checks">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.purpose}
|
||||
className={`company-dns-check${check.verified ? " ok" : " fail"}`}
|
||||
>
|
||||
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
<div>
|
||||
<strong>{check.purpose.toUpperCase()}</strong>
|
||||
<span>{check.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="button button-coral company-dns-next"
|
||||
type="button"
|
||||
disabled={status.loading}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{status.loading ? "Checking DNS…" : "Next — verify DNS"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/ComposeModal.jsx
Normal file
166
frontend/src/components/ComposeModal.jsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
Send,
|
||||
Sparkles,
|
||||
Underline,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { sendEmail } from "../api";
|
||||
|
||||
function parseAddresses(value) {
|
||||
return value
|
||||
.split(/[,\n;]/)
|
||||
.map((address) => address.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function plainTextFromHtml(value) {
|
||||
const document = new DOMParser().parseFromString(value, "text/html");
|
||||
return document.body.textContent || "";
|
||||
}
|
||||
|
||||
export default function ComposeModal({ account, onClose, onSent }) {
|
||||
const [format, setFormat] = useState("plain");
|
||||
const [showCopies, setShowCopies] = useState(false);
|
||||
const [body, setBody] = useState("");
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||
const bodyRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnEscape(event) {
|
||||
if (event.key === "Escape" && !status.sending) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => document.removeEventListener("keydown", closeOnEscape);
|
||||
}, [onClose, status.sending]);
|
||||
|
||||
function helpWrite() {
|
||||
const request = aiPrompt.trim();
|
||||
if (!request) return;
|
||||
const draft = format === "html"
|
||||
? `<p>Hi,</p><p>${request}</p><p>Best,<br>${account.first_name}</p>`
|
||||
: `Hi,\n\n${request}\n\nBest,\n${account.first_name}`;
|
||||
setBody(draft);
|
||||
setAiPrompt("");
|
||||
}
|
||||
|
||||
function applyHtml(openingTag, closingTag) {
|
||||
const editor = bodyRef.current;
|
||||
if (!editor || format !== "html") return;
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const selected = body.slice(start, end) || "text";
|
||||
setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`);
|
||||
window.requestAnimationFrame(() => editor.focus());
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setStatus({ sending: true, error: "" });
|
||||
try {
|
||||
await sendEmail(
|
||||
{
|
||||
recipients: parseAddresses(form.get("to")),
|
||||
cc: parseAddresses(form.get("cc") || ""),
|
||||
bcc: parseAddresses(form.get("bcc") || ""),
|
||||
subject: form.get("subject"),
|
||||
body_text: format === "plain" ? body : plainTextFromHtml(body),
|
||||
body_html: format === "html" ? body : null,
|
||||
},
|
||||
sessionStorage.getItem("agmail_access_token"),
|
||||
);
|
||||
onSent();
|
||||
} catch (error) {
|
||||
setStatus({ sending: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compose-overlay" role="presentation">
|
||||
<section className="compose-modal" role="dialog" aria-modal="true" aria-labelledby="compose-title">
|
||||
<header className="compose-header">
|
||||
<div>
|
||||
<span><Sparkles size={14} /></span>
|
||||
<div><h2 id="compose-title">New message</h2><small>From {account.email}</small></div>
|
||||
</div>
|
||||
<button type="button" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="compose-form" onSubmit={submit}>
|
||||
<div className="compose-recipient-row">
|
||||
<label htmlFor="compose-to">To</label>
|
||||
<input id="compose-to" name="to" type="text" placeholder="name@example.com" required autoFocus />
|
||||
<button type="button" onClick={() => setShowCopies((visible) => !visible)}>Cc · Bcc</button>
|
||||
</div>
|
||||
{showCopies && (
|
||||
<div className="compose-copy-fields">
|
||||
<label>Cc<input name="cc" type="text" placeholder="Copy recipients" /></label>
|
||||
<label>Bcc<input name="bcc" type="text" placeholder="Hidden recipients" /></label>
|
||||
</div>
|
||||
)}
|
||||
<input className="compose-subject" name="subject" placeholder="Subject" maxLength="998" />
|
||||
|
||||
<div className="compose-format-row">
|
||||
<span>Message format</span>
|
||||
<div role="tablist" aria-label="Message format">
|
||||
<button className={format === "plain" ? "active" : ""} type="button" onClick={() => setFormat("plain")}>Plain</button>
|
||||
<button className={format === "html" ? "active" : ""} type="button" onClick={() => setFormat("html")}>HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="compose-body"
|
||||
ref={bodyRef}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={format === "html" ? "<p>Write your HTML email…</p>" : "Write your message…"}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="compose-tools" aria-label="Message tools">
|
||||
<button type="button" title="Bold" disabled={format !== "html"} onClick={() => applyHtml("<strong>", "</strong>")}><Bold size={16} /></button>
|
||||
<button type="button" title="Italic" disabled={format !== "html"} onClick={() => applyHtml("<em>", "</em>")}><Italic size={16} /></button>
|
||||
<button type="button" title="Underline" disabled={format !== "html"} onClick={() => applyHtml("<u>", "</u>")}><Underline size={16} /></button>
|
||||
<button type="button" title="List" disabled={format !== "html"} onClick={() => applyHtml("<ul><li>", "</li></ul>")}><List size={17} /></button>
|
||||
<button type="button" title="Add link" disabled={format !== "html"} onClick={() => applyHtml('<a href="https://">', "</a>")}><Link size={17} /></button>
|
||||
{format === "plain" && <span>Formatting tools are available in HTML mode</span>}
|
||||
</div>
|
||||
|
||||
<div className="compose-ai">
|
||||
<Sparkles size={18} />
|
||||
<input
|
||||
value={aiPrompt}
|
||||
onChange={(event) => setAiPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
helpWrite();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to say"
|
||||
/>
|
||||
<button type="button" onClick={helpWrite} disabled={!aiPrompt.trim()}>
|
||||
Help me write
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status.error && <p className="compose-error" role="alert">{status.error}</p>}
|
||||
<footer className="compose-footer">
|
||||
<span>Sent securely through agmail SMTP</span>
|
||||
<button className="compose-send" type="submit" disabled={status.sending}>
|
||||
{status.sending ? "Sending…" : "Send"} <Send size={16} />
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
frontend/src/components/DashboardPage.jsx
Normal file
213
frontend/src/components/DashboardPage.jsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
Bot,
|
||||
Clock3,
|
||||
Inbox,
|
||||
ListFilter,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
Plug,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentAccount, getMyCompanyDomain } from "../api";
|
||||
import Brand from "./Brand";
|
||||
import CompanyDnsSetup from "./CompanyDnsSetup";
|
||||
import ComposeModal from "./ComposeModal";
|
||||
import MailboxView from "./MailboxView";
|
||||
|
||||
const mailItems = [
|
||||
[Inbox, "Inbox"],
|
||||
[Send, "Outbox"],
|
||||
[ShieldAlert, "Spam"],
|
||||
[Trash2, "Trash"],
|
||||
[Clock3, "Scheduled"],
|
||||
[Settings, "Settings"],
|
||||
];
|
||||
|
||||
const agentItems = [
|
||||
[Tags, "Tags"],
|
||||
[ListFilter, "Filters"],
|
||||
[Sparkles, "Summary"],
|
||||
[Plug, "Integration"],
|
||||
];
|
||||
|
||||
function clearSession() {
|
||||
sessionStorage.removeItem("agmail_access_token");
|
||||
sessionStorage.removeItem("agmail_account");
|
||||
}
|
||||
|
||||
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
|
||||
return (
|
||||
<section className="dashboard-nav-group">
|
||||
<h2><Icon size={15} />{title}</h2>
|
||||
<nav aria-label={title}>
|
||||
{items.map(([ItemIcon, label]) => (
|
||||
<button
|
||||
className={active === label ? "active" : ""}
|
||||
key={label}
|
||||
onClick={() => onSelect(label)}
|
||||
type="button"
|
||||
>
|
||||
<ItemIcon size={17} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [account, setAccount] = useState(null);
|
||||
const [companyDomain, setCompanyDomain] = useState(null);
|
||||
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
|
||||
const [domainError, setDomainError] = useState("");
|
||||
const [active, setActive] = useState("Inbox");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
if (!token) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
getCurrentAccount(token)
|
||||
.then(async (currentAccount) => {
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
||||
setAccount(currentAccount);
|
||||
if (currentAccount.account_type === "company") {
|
||||
try {
|
||||
const domain = await getMyCompanyDomain(token);
|
||||
setCompanyDomain(domain);
|
||||
setNeedsDnsSetup(domain.status === "pending_dns");
|
||||
} catch (error) {
|
||||
setDomainError(error.message || "Could not load your company domain.");
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
clearSession();
|
||||
window.location.replace("/login");
|
||||
});
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
clearSession();
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
setActive(item);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function emailSent() {
|
||||
setComposeOpen(false);
|
||||
setActive("Outbox");
|
||||
setMailRefreshKey((key) => key + 1);
|
||||
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
|
||||
window.setTimeout(() => setNotice(""), 4000);
|
||||
}
|
||||
|
||||
if (loading || !account) {
|
||||
return <main className="dashboard-loading"><Brand /><span>Opening your workspace…</span></main>;
|
||||
}
|
||||
|
||||
if (account.account_type === "company" && domainError) {
|
||||
return (
|
||||
<main className="company-dns-setup">
|
||||
<header className="company-dns-header">
|
||||
<Brand />
|
||||
<button type="button" onClick={logout}>Log out</button>
|
||||
</header>
|
||||
<section className="company-dns-card">
|
||||
<p className="eyebrow">Company setup</p>
|
||||
<h1>Domain not ready</h1>
|
||||
<p className="form-error" role="alert">{domainError}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsDnsSetup && companyDomain) {
|
||||
return (
|
||||
<CompanyDnsSetup
|
||||
account={account}
|
||||
domain={companyDomain}
|
||||
onComplete={() => {
|
||||
setCompanyDomain({ ...companyDomain, status: "active" });
|
||||
setNeedsDnsSetup(false);
|
||||
}}
|
||||
onLogout={logout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceLabel = account.account_type === "company" ? "Company" : "Individual";
|
||||
const workspaceTitle = account.account_type === "company" && companyDomain
|
||||
? companyDomain.domain_name
|
||||
: "Personal workspace";
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-mobile-header">
|
||||
<Brand />
|
||||
<button aria-label="Open navigation" type="button" onClick={() => setMenuOpen(true)}>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
|
||||
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
|
||||
<div className="dashboard-brand-row">
|
||||
<Brand />
|
||||
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
|
||||
</div>
|
||||
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
||||
<Mail size={17} />Compose
|
||||
</button>
|
||||
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
|
||||
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
|
||||
<div className="dashboard-profile">
|
||||
<span>{account.first_name[0]}{account.last_name[0]}</span>
|
||||
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
|
||||
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="dashboard-content">
|
||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||
<div className="dashboard-content-heading">
|
||||
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
|
||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
|
||||
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
|
||||
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
|
||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||
<section className="dashboard-empty">
|
||||
<span><Sparkles size={24} /></span>
|
||||
<h2>{active} is ready for you</h2>
|
||||
<p>Your personal mail and agent tools will appear here.</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
|
||||
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/LoginPage.jsx
Normal file
46
frontend/src/components/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const result = await login({
|
||||
email: form.get("email"),
|
||||
password: form.get("password"),
|
||||
});
|
||||
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||
window.location.assign("/app");
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Welcome back"
|
||||
title={<>Your inbox<br /><em>missed you.</em></>}
|
||||
description="Sign in to continue working alongside your agents."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/MailDetail.jsx
Normal file
110
frontend/src/components/MailDetail.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
MailOpen,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import SenderAvatar from "./SenderAvatar";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
|
||||
function joinAddresses(value) {
|
||||
if (!value) return "";
|
||||
const addresses = Array.isArray(value) ? value : [value];
|
||||
return addresses
|
||||
.map((item) => (typeof item === "string" ? item : item?.email || item?.address))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function detailDate(email) {
|
||||
const value =
|
||||
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function MailDetail({
|
||||
email,
|
||||
loading,
|
||||
error,
|
||||
mailbox,
|
||||
busy,
|
||||
onClose,
|
||||
onToggleRead,
|
||||
onTrash,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}) {
|
||||
return (
|
||||
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
|
||||
<header className="mail-detail-toolbar">
|
||||
<button type="button" onClick={onClose} aria-label="Back to message list">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div>
|
||||
{email && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleRead(!email.is_read)}
|
||||
title={email.is_read ? "Mark unread" : "Mark read"}
|
||||
aria-label={email.is_read ? "Mark unread" : "Mark read"}
|
||||
>
|
||||
{email.is_read ? <Mail size={17} /> : <MailOpen size={17} />}
|
||||
</button>
|
||||
)}
|
||||
{email && mailbox === "trash" && (
|
||||
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
|
||||
<RotateCcw size={17} />
|
||||
</button>
|
||||
)}
|
||||
{email && (
|
||||
<button
|
||||
className={mailbox === "trash" ? "danger" : ""}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={mailbox === "trash" ? onDelete : onTrash}
|
||||
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
|
||||
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
)}
|
||||
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <div className="mail-detail-state">Opening message…</div>}
|
||||
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
|
||||
{email && !loading && (
|
||||
<div className="mail-detail-content">
|
||||
<h2>{email.subject?.trim() || "(No subject)"}</h2>
|
||||
<div className="mail-detail-meta">
|
||||
<div>
|
||||
<div className="mail-detail-sender">
|
||||
<SenderAvatar email={email} />
|
||||
<strong>{senderDisplayName(email)}</strong>
|
||||
</div>
|
||||
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
|
||||
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
|
||||
</div>
|
||||
<time>{detailDate(email)}</time>
|
||||
</div>
|
||||
<div className="mail-detail-body">
|
||||
{email.body_text || email.text_body || email.plain_text || "This message has no plain-text content."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/MailRow.jsx
Normal file
75
frontend/src/components/MailRow.jsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { MailOpen, Star } from "lucide-react";
|
||||
import SenderAvatar from "./SenderAvatar";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
||||
const unread = email.is_read === false;
|
||||
const starred = email.is_starred === true;
|
||||
const subject = email.subject?.trim() || "(No subject)";
|
||||
const snippet = email.snippet || email.preview || email.body_text || "";
|
||||
const timestamp =
|
||||
email.received_at || email.sent_at || email.created_at || email.updated_at;
|
||||
const senderName = senderDisplayName(email);
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(email);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarClick(event) {
|
||||
event.stopPropagation();
|
||||
onToggleStar?.(email, !starred);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mailbox-row${unread ? " unread" : ""}${selected ? " selected" : ""}${starred ? " starred" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(email)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
|
||||
>
|
||||
<span className="mailbox-read-dot" aria-hidden="true">
|
||||
{unread ? <i /> : <MailOpen size={14} />}
|
||||
</span>
|
||||
<SenderAvatar email={email} className="mailbox-avatar" />
|
||||
<span className="mailbox-sender">{senderName}</span>
|
||||
<span className="mailbox-summary">
|
||||
<strong>{subject}</strong>
|
||||
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
|
||||
</span>
|
||||
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
|
||||
<button
|
||||
type="button"
|
||||
className={`mailbox-star${starred ? " active" : ""}`}
|
||||
onClick={handleStarClick}
|
||||
aria-label={starred ? "Unstar message" : "Star message"}
|
||||
aria-pressed={starred}
|
||||
>
|
||||
<Star size={15} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
frontend/src/components/MailboxView.jsx
Normal file
304
frontend/src/components/MailboxView.jsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getEmail,
|
||||
listEmails,
|
||||
permanentlyDeleteEmail,
|
||||
restoreEmail,
|
||||
setEmailRead,
|
||||
setEmailStar,
|
||||
trashEmail,
|
||||
} from "../api";
|
||||
import MailDetail from "./MailDetail";
|
||||
import MailRow from "./MailRow";
|
||||
import StickyNotesPanel from "./StickyNotesPanel";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function resultPage(result) {
|
||||
if (Array.isArray(result)) return { items: result, nextCursor: null };
|
||||
return {
|
||||
items: result.items || result.emails || result.results || [],
|
||||
nextCursor: result.next_cursor || result.nextCursor || null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||
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 [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 [filters, setFilters] = useState({
|
||||
query: "",
|
||||
read: "all",
|
||||
sender: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus({ loading: true, error: "" });
|
||||
try {
|
||||
const result = await listEmails(
|
||||
{
|
||||
mailbox,
|
||||
limit: PAGE_SIZE,
|
||||
cursor,
|
||||
q: filters.query.trim(),
|
||||
is_read: filters.read === "all" ? undefined : filters.read === "read",
|
||||
sender: filters.sender.trim(),
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
const page = resultPage(result);
|
||||
setEmails(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}, [cursor, filters, mailbox, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load, refreshKey]);
|
||||
|
||||
function changeFilter(name, value) {
|
||||
setFilters((current) => ({ ...current, [name]: value }));
|
||||
setCursor(null);
|
||||
setCursorHistory([]);
|
||||
}
|
||||
|
||||
async function openEmail(email) {
|
||||
setSelected(email.id);
|
||||
setDetail(email);
|
||||
setDetailStatus({ loading: true, error: "", busy: false });
|
||||
if (email.is_read === false) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
|
||||
);
|
||||
setDetail((current) => ({ ...current, is_read: true }));
|
||||
setEmailRead(email.id, true, token).catch(() => {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||||
);
|
||||
setDetail((current) =>
|
||||
current?.id === email.id ? { ...current, is_read: false } : current,
|
||||
);
|
||||
});
|
||||
}
|
||||
try {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStar(email, isStarred) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
|
||||
);
|
||||
if (detail?.id === email.id) {
|
||||
setDetail((current) => ({ ...current, is_starred: isStarred }));
|
||||
}
|
||||
try {
|
||||
await setEmailStar(email.id, isStarred, token);
|
||||
} catch (error) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
|
||||
);
|
||||
if (detail?.id === email.id) {
|
||||
setDetail((current) => ({ ...current, is_starred: !isStarred }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRead(isRead) {
|
||||
if (!detail) return;
|
||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||
try {
|
||||
await setEmailRead(detail.id, isRead, token);
|
||||
setDetail((current) => ({ ...current, is_read: isRead }));
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
|
||||
);
|
||||
setDetailStatus((current) => ({ ...current, busy: false }));
|
||||
} catch (error) {
|
||||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateSelected(action) {
|
||||
if (!detail) return;
|
||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||
try {
|
||||
await action(detail.id, token);
|
||||
setEmails((current) => current.filter((item) => item.id !== detail.id));
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function deletePermanently() {
|
||||
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
|
||||
mutateSelected(permanentlyDeleteEmail);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (!nextCursor) return;
|
||||
setCursorHistory((history) => [...history, cursor]);
|
||||
setCursor(nextCursor);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (!cursorHistory.length) return;
|
||||
const previous = cursorHistory[cursorHistory.length - 1];
|
||||
setCursorHistory((history) => history.slice(0, -1));
|
||||
setCursor(previous);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
const hasFilters =
|
||||
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
|
||||
|
||||
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>
|
||||
</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 && 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>
|
||||
|
||||
<StickyNotesPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/MobileLanding.jsx
Normal file
34
frontend/src/components/MobileLanding.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
export default function MobileLanding() {
|
||||
return (
|
||||
<main className="mobile-landing">
|
||||
<nav className="mobile-nav">
|
||||
<Brand />
|
||||
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>
|
||||
</nav>
|
||||
<section className="mobile-hero">
|
||||
<div className="eyebrow"><Sparkles size={13} /> Email for humans + agents</div>
|
||||
<h1>Your inbox,<br /><em>one step ahead.</em></h1>
|
||||
<p>A focused home for every message, task, and AI agent you work with.</p>
|
||||
<a className="button button-coral" href="/signup">Create your agmail <ArrowRight size={17} /></a>
|
||||
<a className="mobile-login" href="/login">Already have an account? <strong>Log in</strong></a>
|
||||
</section>
|
||||
<section className="phone-preview" aria-label="Agmail mobile inbox preview">
|
||||
<div className="phone-top"><span>9:41</span><span className="phone-pill" /><span>•••</span></div>
|
||||
<header><div><small>Good morning</small><strong>Alex</strong></div><span className="avatar">AK</span></header>
|
||||
<div className="mobile-summary">
|
||||
<span className="agent-icon"><Bot size={18} /></span>
|
||||
<div><strong>Your agent caught you up</strong><small>8 emails handled this morning</small></div>
|
||||
<ArrowRight size={15} />
|
||||
</div>
|
||||
<div className="mobile-inbox-title"><h3>Priority inbox</h3><span>4 new</span></div>
|
||||
<article className="mobile-mail"><span className="sender-avatar coral">R</span><div><strong>Research agent</strong><p>Market brief is ready</p></div><time>10:42</time></article>
|
||||
<article className="mobile-mail"><span className="sender-avatar blue">M</span><div><strong>Maya Chen</strong><p>Re: Product direction</p></div><time>9:18</time></article>
|
||||
<div className="mobile-tabbar"><span className="active"><Inbox size={19} /></span><button><span>+</span></button><span><Send size={19} /></span></div>
|
||||
</section>
|
||||
<section className="mobile-benefits"><span><Check size={15} /> Free to start</span><span><Check size={15} /> Private by design</span></section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/SenderAvatar.jsx
Normal file
23
frontend/src/components/SenderAvatar.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { senderAvatar } from "../utils/senderAvatar";
|
||||
|
||||
export default function SenderAvatar({ email, className = "" }) {
|
||||
const { name, initials, color, imageUrl } = senderAvatar(email);
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={`sender-avatar${className ? ` ${className}` : ""}`}>
|
||||
<img src={imageUrl} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`sender-avatar${className ? ` ${className}` : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/SignupPage.jsx
Normal file
80
frontend/src/components/SignupPage.jsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { register } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [accountType, setAccountType] = useState("individual");
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const result = await register({
|
||||
account_type: form.get("account_type"),
|
||||
email_name: form.get("email_name"),
|
||||
company_domain: form.get("company_domain") || null,
|
||||
first_name: form.get("first_name"),
|
||||
last_name: form.get("last_name"),
|
||||
date_of_birth: form.get("date_of_birth"),
|
||||
phone_number: form.get("phone_number"),
|
||||
password: form.get("password"),
|
||||
});
|
||||
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||
window.location.assign("/app");
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Your new digital address"
|
||||
title={<>An inbox built<br /><em>around you.</em></>}
|
||||
description="Create a personal address or bring your company's domain."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading"><span>01 — Account setup</span><h2>Create your account</h2><p>Every field is securely processed by agmail.</p></div>
|
||||
<form className="auth-form signup-form" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<legend>Account type</legend>
|
||||
<div className="account-types">
|
||||
<label className={accountType === "individual" ? "type-card selected" : "type-card"}>
|
||||
<input type="radio" name="account_type" value="individual" checked={accountType === "individual"} onChange={(event) => setAccountType(event.target.value)} />
|
||||
<UserRound size={19} /><span><strong>Individual</strong><small>Your personal agent inbox</small></span>
|
||||
</label>
|
||||
<label className={accountType === "company" ? "type-card selected" : "type-card"}>
|
||||
<input type="radio" name="account_type" value="company" checked={accountType === "company"} onChange={(event) => setAccountType(event.target.value)} />
|
||||
<Building2 size={19} /><span><strong>Company</strong><small>Use your business domain</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{accountType === "company" && (
|
||||
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
|
||||
)}
|
||||
<label>Choose your email name
|
||||
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
|
||||
</label>
|
||||
<div className="field-row">
|
||||
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
|
||||
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
|
||||
</div>
|
||||
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
|
||||
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-switch">Already have an account? <a href="/login">Log in</a></p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal file
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { ArrowRight, Copy, Globe, Server } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { previewDnsSetup } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
const STORAGE_KEY = "agmail_dns_setup_auth";
|
||||
|
||||
function loadStoredAuth() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.username || !parsed?.password) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DnsRecordTable({ title, records }) {
|
||||
if (!records?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copyValue(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
/* clipboard may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dns-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="dns-table-wrap">
|
||||
<table className="dns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Host</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
|
||||
const displayValue = record.priority
|
||||
? `${record.priority} ${record.value}`
|
||||
: record.value;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>{record.record_type}</td>
|
||||
<td><code>{record.host}</code></td>
|
||||
<td>
|
||||
<div className="dns-value-cell">
|
||||
<code>{displayValue}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="dns-copy"
|
||||
aria-label="Copy value"
|
||||
onClick={() => copyValue(displayValue)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{record.ttl}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SmtpCredentialsPage() {
|
||||
const [auth, setAuth] = useState(() => loadStoredAuth());
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setResult(null);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
const nextAuth = {
|
||||
username: String(form.get("username") || ""),
|
||||
password: String(form.get("password") || ""),
|
||||
};
|
||||
try {
|
||||
await previewDnsSetup(
|
||||
{ domain: "example.com", mail_server_ip: "127.0.0.1" },
|
||||
nextAuth,
|
||||
);
|
||||
setAuth(nextAuth);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(event) {
|
||||
event.preventDefault();
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const data = await previewDnsSetup(
|
||||
{
|
||||
domain: String(form.get("domain") || ""),
|
||||
mail_server_ip: String(form.get("mail_server_ip") || ""),
|
||||
},
|
||||
auth,
|
||||
);
|
||||
setResult(data);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
if (error.message.toLowerCase().includes("credential")) {
|
||||
setAuth(null);
|
||||
}
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Operations"
|
||||
title={<>DNS setup<br /><em>for mail.</em></>}
|
||||
description="Sign in to generate Namecheap-ready DNS records for self-hosted inbound and outbound mail."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>DNS setup access</h2>
|
||||
<p>Restricted to deployment operators.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={handleLogin}>
|
||||
<label>
|
||||
Username
|
||||
<div className="input-wrap">
|
||||
<Server size={16} />
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="smtp-credentia"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Passkey
|
||||
<div className="input-wrap">
|
||||
<Globe size={16} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Your passkey"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Checking…" : "Continue"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Operations"
|
||||
title={<>DNS records<br /><em>ready to paste.</em></>}
|
||||
description="Enter the master domain and this server's public IP. Records are formatted for Namecheap Advanced DNS."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>Generate DNS configuration</h2>
|
||||
<p>Inbound delivery and outbound authentication records for this mail host.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={handleGenerate}>
|
||||
<label>
|
||||
Master domain
|
||||
<div className="input-wrap">
|
||||
<Globe size={16} />
|
||||
<input
|
||||
name="domain"
|
||||
type="text"
|
||||
placeholder="getall.help"
|
||||
defaultValue={result?.domain || "getall.help"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Mail server public IP
|
||||
<div className="input-wrap">
|
||||
<Server size={16} />
|
||||
<input
|
||||
name="mail_server_ip"
|
||||
type="text"
|
||||
placeholder="203.0.113.10"
|
||||
defaultValue={result?.mail_server_ip || ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Generating…" : "Generate DNS records"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{result && (
|
||||
<div className="dns-results">
|
||||
<p className="dns-summary">
|
||||
<strong>{result.mail_host}</strong> → {result.mail_server_ip}
|
||||
{result.app_public_host && (
|
||||
<> · web UI at <strong>{result.app_public_host}</strong></>
|
||||
)}
|
||||
{" · self-hosted inbound + outbound"}
|
||||
</p>
|
||||
<ul className="dns-notes">
|
||||
{result.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
<DnsRecordTable
|
||||
title="Inbound (MX, A — deliver mail to this host)"
|
||||
records={result.sections.inbound}
|
||||
/>
|
||||
<DnsRecordTable
|
||||
title="Outbound (SPF, DKIM, DMARC — authenticate sending)"
|
||||
records={result.sections.outbound}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="auth-switch">
|
||||
<button type="button" className="link-button" onClick={() => setAuth(null)}>
|
||||
Sign out
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import {
|
||||
MoreHorizontal,
|
||||
Pin,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createStickyNote,
|
||||
deleteStickyNote,
|
||||
listStickyNotes,
|
||||
} from "../api";
|
||||
import bluePin from "../data/blue.png";
|
||||
import greenPin from "../data/green.png";
|
||||
import pinkPin from "../data/pink.png";
|
||||
import yellowPin from "../data/yellow.png";
|
||||
|
||||
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
|
||||
const PIN_IMAGES = {
|
||||
yellow: yellowPin,
|
||||
green: greenPin,
|
||||
blue: bluePin,
|
||||
pink: pinkPin,
|
||||
};
|
||||
|
||||
function formatNoteTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
|
||||
return (
|
||||
<article className={`sticky-note sticky-note-${note.color}`}>
|
||||
<span className="sticky-note-pin" aria-hidden="true">
|
||||
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
|
||||
</span>
|
||||
<header className="sticky-note-header">
|
||||
<h3>{note.title}</h3>
|
||||
<div className="sticky-note-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-menu-btn"
|
||||
aria-label="Note options"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMenuToggle(note.id);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="sticky-note-menu">
|
||||
<button type="button" onClick={() => onDelete(note.id)}>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{note.body && <p>{note.body}</p>}
|
||||
<footer className="sticky-note-footer">
|
||||
<div className="sticky-note-tags">
|
||||
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StickyNotesPanel() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [menuOpenId, setMenuOpenId] = useState(null);
|
||||
const [draft, setDraft] = useState({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: "yellow",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listStickyNotes(token);
|
||||
setNotes(items);
|
||||
} catch (loadError) {
|
||||
setError(loadError.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
function closeMenu() {
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
window.addEventListener("click", closeMenu);
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, []);
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!draft.title.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createStickyNote(
|
||||
{
|
||||
title: draft.title.trim(),
|
||||
body: draft.body.trim(),
|
||||
tags: draft.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
color: draft.color,
|
||||
},
|
||||
token,
|
||||
);
|
||||
setNotes((current) => [created, ...current]);
|
||||
setDraft({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
|
||||
});
|
||||
setAdding(false);
|
||||
} catch (createError) {
|
||||
setError(createError.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(noteId) {
|
||||
setMenuOpenId(null);
|
||||
setError("");
|
||||
try {
|
||||
await deleteStickyNote(noteId, token);
|
||||
setNotes((current) => current.filter((note) => note.id !== noteId));
|
||||
} catch (deleteError) {
|
||||
setError(deleteError.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBodyKeyDown(event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const insertion = "\n• ";
|
||||
const nextValue =
|
||||
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
|
||||
setDraft((current) => ({ ...current, body: nextValue }));
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.selectionStart = selectionStart + insertion.length;
|
||||
textarea.selectionEnd = selectionStart + insertion.length;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
|
||||
<header className="sticky-notes-header">
|
||||
<div className="sticky-notes-title">
|
||||
<Pin size={15} />
|
||||
<h2>Sticky Notes</h2>
|
||||
</div>
|
||||
<div className="sticky-notes-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add sticky note"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="sticky-notes-body">
|
||||
{loading && <div className="sticky-notes-state">Loading notes…</div>}
|
||||
{error && !loading && (
|
||||
<div className="sticky-notes-state error" role="alert">{error}</div>
|
||||
)}
|
||||
{!loading && notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
menuOpen={menuOpenId === note.id}
|
||||
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<form className="sticky-note-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="Note title"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
placeholder="Jot down a quick note…"
|
||||
rows={3}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.tags}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
|
||||
placeholder="Tags (comma separated)"
|
||||
/>
|
||||
<div className="sticky-note-colors">
|
||||
{NOTE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
|
||||
data-color={color}
|
||||
aria-label={`${color} note`}
|
||||
onClick={() => setDraft((current) => ({ ...current, color }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="sticky-note-form-actions">
|
||||
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving || !draft.title.trim()}>
|
||||
{saving ? "Saving…" : "Save note"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-add"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Add sticky note</span>
|
||||
<small>Jot down a quick note…</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/WebLanding.jsx
Normal file
65
frontend/src/components/WebLanding.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
ArrowRight, Bot, Check, ChevronDown, Clock3, Inbox, Menu,
|
||||
Paperclip, Search, Send, ShieldCheck, Sparkles,
|
||||
} from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
const mail = [
|
||||
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
|
||||
{ sender: "Maya Chen", subject: "Re: Product direction", time: "9:18", color: "blue" },
|
||||
{ sender: "Calendar", subject: "Design review in 30 minutes", time: "8:30", color: "gold" },
|
||||
];
|
||||
|
||||
export default function WebLanding() {
|
||||
return (
|
||||
<main className="landing">
|
||||
<nav className="nav shell">
|
||||
<Brand />
|
||||
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>
|
||||
<div className="nav-actions"><a className="button button-quiet" href="/login">Log in</a><a className="button button-dark" href="/signup">Create account <ArrowRight size={15} /></a></div>
|
||||
</nav>
|
||||
<section className="hero shell">
|
||||
<div className="hero-copy">
|
||||
<div className="eyebrow"><Sparkles size={14} /> Built for you and your agents</div>
|
||||
<h1>Email that works<br /><em>at your speed.</em></h1>
|
||||
<p>A calm, intelligent inbox where you and your AI agents can send, receive, organize, and get work done.</p>
|
||||
<div className="hero-actions"><a className="button button-coral" href="/signup">Get your @agmail <ArrowRight size={17} /></a><a className="button button-outline" href="#product">See how it works</a></div>
|
||||
<div className="trust-line"><span><Check size={13} /> Free to start</span><span><Check size={13} /> No card required</span></div>
|
||||
</div>
|
||||
<div className="product-stage" aria-label="Agmail inbox preview">
|
||||
<div className="orb orb-one" /><div className="orb orb-two" />
|
||||
<div className="inbox-window">
|
||||
<aside className="inbox-sidebar">
|
||||
<div className="mini-brand"><Brand compact /></div>
|
||||
<button className="compose"><span>+</span></button>
|
||||
<div className="side-icons"><span className="active"><Inbox size={18} /></span><span><Send size={18} /></span><span><Clock3 size={18} /></span></div>
|
||||
<div className="avatar">AK</div>
|
||||
</aside>
|
||||
<div className="mail-panel">
|
||||
<header className="mail-toolbar"><div><small>Good morning,</small><strong>Alex</strong></div><div className="toolbar-actions"><Search size={17} /><Menu size={17} /></div></header>
|
||||
<div className="inbox-heading"><div><h3>Inbox</h3><span>12</span></div><button>Priority <ChevronDown size={13} /></button></div>
|
||||
<div className="agent-summary"><div className="agent-icon"><Bot size={19} /></div><div><strong>Your agent handled 8 emails</strong><p>3 replies drafted · 2 tasks completed</p></div><ArrowRight size={16} /></div>
|
||||
<div className="mail-list">
|
||||
{mail.map((item, index) => (
|
||||
<article className={index === 0 ? "mail-item selected" : "mail-item"} key={item.sender}>
|
||||
<span className={`sender-avatar ${item.color}`}>{item.sender[0]}</span><div><strong>{item.sender}</strong><p>{item.subject}</p></div><time>{item.time}</time>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="quick-reply"><Paperclip size={16} /><span>Ask your agent to draft a reply…</span><button><ArrowRight size={15} /></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="floating-note"><span><Check size={14} /></span><div><strong>Follow-up sent</strong><small>Handled by your agent</small></div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="feature-strip shell" id="product">
|
||||
<div className="feature-intro"><span>One inbox. Less noise.</span><h2>Made for the way work is changing.</h2></div>
|
||||
<div className="feature-grid">
|
||||
<article><span className="feature-icon peach"><Bot size={21} /></span><h3>Agent-ready</h3><p>Give every agent a secure address and clear permissions.</p></article>
|
||||
<article><span className="feature-icon cyan"><Sparkles size={21} /></span><h3>Quietly intelligent</h3><p>Summaries, sorting, and replies—without the clutter.</p></article>
|
||||
<article id="security"><span className="feature-icon violet"><ShieldCheck size={21} /></span><h3>Private by design</h3><p>Your conversations stay encrypted and in your control.</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue