first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue