function Sidebar({ view, tagView, setView, tags, unread, total, status, onCreateTag, onRenameTag, onDeleteTag, onLogout, token, account, onComposeClick, brand, onCloseNav }) {
const { t } = useI18n();
const [newTag, setNewTag] = React.useState("");
const [tagBusy, setTagBusy] = React.useState(false);
const navBtn = (active, hasUnread) =>
"w-full text-left px-3 py-2 rounded-md text-sm mb-0.5 " +
(active
? "bg-accent-soft text-accent"
: hasUnread
? "text-ink font-semibold hover:bg-paper-line/50"
: "text-ink-muted hover:bg-paper-line/50 hover:text-ink");
const userTags = (tags || []).filter((t) => t !== "archive");
const label = (name, n) => (n > 0 ? name + " (" + n + ")" : name);
const submitTag = (e) => {
e.preventDefault();
const name = (newTag || "").trim();
if (!name || tagBusy) return;
setTagBusy(true);
onCreateTag(name)
.then(() => setNewTag(""))
.catch((err) => console.error(err))
.finally(() => setTagBusy(false));
};
return (
);
}
function Toolbar({ q, setQ, onSearch, selCount, onBulk, onMarkAll, view, tags, busy }) {
const { t } = useI18n();
const n = selCount;
const btn =
"text-xs px-2.5 py-1 rounded border border-paper-line text-ink-muted hover:border-accent hover:text-accent disabled:opacity-30";
const userTags = (tags || []).filter((t) => t !== "archive");
return (
onBulk("mark_read")}>
{t("mark_read_n", n)}
{view === "archive" ? (
onBulk("unarchive")}>
{t("unarchive_n", n)}
) : (
onBulk("archive")}>
{t("archive_n", n)}
)}
onBulk("star")}>
{t("star_n", n)}
onBulk("unstar")}>
{t("unstar_n", n)}
onBulk("delete")}>
{t("delete_n", n)}
{t("mark_all_read")}
{n > 0 && userTags.length > 0 && (
{
const t = e.target.value;
e.target.value = "";
if (t) onBulk("tag", t);
}}
>
{t("tag_n", n)}
{userTags.map((t) => (
#{t}
))}
)}
);
}
function MessageList({
items,
selected,
onSelect,
onStar,
loading,
checked,
setChecked,
total,
selectAllPages,
setSelectAllPages,
}) {
const { t, lang } = useI18n();
const allIds = items.map((m) => m.id);
const pageOn = allIds.length > 0 && allIds.every((id) => checked.includes(id));
const multiPage = total > allIds.length && allIds.length > 0;
if (loading) return {t("loading")}
;
if (!items.length) {
return {t("no_messages")}
;
}
return (
{items.map((m) => {
const active = selected === m.id;
const on = selectAllPages || checked.includes(m.id);
return (
{
e.stopPropagation();
setSelectAllPages(false);
setChecked(
e.target.checked ? checked.concat([m.id]) : checked.filter((x) => x !== m.id)
);
}}
/>
{
e.stopPropagation();
onStar(m.id, !m.starred);
}}
>
{m.starred ? "★" : "☆"}
onSelect(m.id)}>
{m.direction === "out" ? t("to_prefix", m.to_addr || "") : m.from_addr}
{formatWhen(m.created_at, lang)}
{m.subject}
{m.preview}
);
})}
);
}
function StorageBar({ token }) {
const { t } = useI18n();
const [usage, setUsage] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
if (!token) return;
let active = true;
const fetchUsage = () => {
fetch("/api/mailbox/usage", { headers: { Authorization: "Bearer " + token } })
.then((r) => r.json())
.then((j) => { if (active && j.ok) { setUsage(j.data); setLoading(false); } })
.catch(() => {});
};
fetchUsage();
const interval = setInterval(fetchUsage, 30000);
return () => { active = false; clearInterval(interval); };
}, [token]);
// Admin tokens have no mailbox, so the endpoint answers with a per-mailbox
// breakdown instead of a single figure — nothing to show in a per-user bar.
if (loading || !usage || usage.mailboxes || typeof usage.used_bytes !== "number") return null;
const fmt = (b) => {
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB";
if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB";
if (b >= 1024) return (b / 1024).toFixed(0) + " KB";
return b + " B";
};
const pct = Math.min(100, Math.round(usage.percent * 10) / 10);
const barWidth = Math.max(2, usage.percent);
const barColor = pct > 90 ? "bg-red-400" : pct > 70 ? "bg-yellow-400" : "bg-accent";
return (
{t("storage")}
{fmt(usage.used_bytes)} / {fmt(usage.max_bytes)}
{pct}% · {t("msgs", usage.message_count)}
);
}
// Mail disappearing on a schedule is surprising unless the schedule is stated.
function RetentionNote({ usage }) {
const { t } = useI18n();
const months = Number(usage.retention_months);
if (!months || months <= 0) return null;
const period = t("months", months);
const cap = usage.max_messages ? t("capped_at", usage.max_messages.toLocaleString()) : "";
return (
{t("kept_months", period)} · {t("kept_starred")}
);
}
function ThemeToggle() {
const { t } = useI18n();
const [theme, toggle] = useTheme();
const light = theme === "light";
return (
{light ? t("theme_light") : t("theme_dark")}
{light ? "☀" : "☾"}
);
}
function TagRow({ name, count, active, navBtn, label, onOpen, onRename, onDelete }) {
const { t } = useI18n();
const [busy, setBusy] = React.useState(false);
const act =
"px-1 text-[0.7rem] text-ink-dim hover:text-accent disabled:opacity-30";
const rename = (e) => {
e.stopPropagation();
const next = window.prompt(t("rename_prompt", name), name);
if (!next || next.trim() === name) return;
setBusy(true);
Promise.resolve(onRename(name, next.trim()))
.catch((err) => alert(String(err.message || err)))
.finally(() => setBusy(false));
};
const remove = (e) => {
e.stopPropagation();
if (!window.confirm(t("delete_confirm", name))) return;
setBusy(true);
Promise.resolve(onDelete(name))
.catch((err) => alert(String(err.message || err)))
.finally(() => setBusy(false));
};
return (
0) + " flex-1 min-w-0 truncate"} onClick={onOpen}>
{label("#" + name, count)}
✎
✕
);
}
function CopyAddress({ address }) {
const { t } = useI18n();
const [state, setState] = React.useState("");
const copy = () => {
copyToClipboard(address)
.then(() => setState(t("copied")))
.catch(() => setState(t("copy_blocked")))
.finally(() => setTimeout(() => setState(""), 1800));
};
return (
{state || address}
);
}