Menu
Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9
AkurAI-Mail / src/framework/settings-island.js
Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson
// settings-island.js — the settings drawer as a framework island.
//
// The drawer's inputs are built once and keep their DOM; the dynamic parts (scheduled sends,
// account status, theme, contacts, rules) re-render from the store. Opening the drawer loads the
// saved signature and vacation reply into the fields and refreshes scheduled sends.
import { effect, signal } from "./vendor/signals.js";
import { h, replace } from "./dom.js";
import { icon } from "./icons.js";
import { RULE_ACTIONS, RULE_FIELDS } from "./store.js";
import { SYNC_ERROR_MESSAGES } from "../lib/webmail/sync.js";
const DEFAULT_VACATION = () => ({ enabled: false, subject: "Out of office", body: "" });
/**
* A button that opens a small menu of choices.
* @param {() => string} value
* @param {string[]} choices
* @param {(choice: string) => void} onChoose
*/
function choiceMenu(value, choices, onChoose) {
const open = signal(false);
const wrap = h("div", { class: "menu-wrap" });
effect(() => {
replace(wrap, [
h("button", { class: "field-button", "aria-haspopup": "menu", "aria-expanded": String(open()), onclick: () => open(!open()) }, value(), icon("ChevronDown", { size: 15 })),
open() ? h("div", { class: "popup-menu", role: "menu" }, choices.map((choice) => h("button", { role: "menuitem", onclick: () => { onChoose(choice); open(false); } }, choice))) : null,
]);
});
return wrap;
}
/**
* @param {HTMLElement} root
* @param {ReturnType<typeof import("./store.js").createStore>} store
*/
export function mountSettings(root, store) {
const contactName = /** @type {HTMLInputElement} */ (h("input", { placeholder: "Name" }));
const contactEmail = /** @type {HTMLInputElement} */ (h("input", { placeholder: "email@example.com" }));
const signature = /** @type {HTMLTextAreaElement} */ (h("textarea", { placeholder: "Signature for this mailbox" }));
const vacationEnabled = /** @type {HTMLInputElement} */ (h("input", { type: "checkbox" }));
const vacationSubject = /** @type {HTMLInputElement} */ (h("input", { placeholder: "Subject" }));
const vacationBody = /** @type {HTMLTextAreaElement} */ (h("textarea", { placeholder: "Reply body" }));
const ruleContains = /** @type {HTMLInputElement} */ (h("input", { placeholder: "contains" }));
const importText = h("textarea", { placeholder: "Settings JSON" });
const rule = signal({ field: "from", contains: "", action: "Archive", enabled: true });
function hydrateFields() {
signature.value = store.activeSignature();
const vacation = { ...DEFAULT_VACATION(), ...(store.vacation() ?? {}) };
vacationEnabled.checked = !!vacation.enabled;
vacationSubject.value = vacation.subject ?? "";
vacationBody.value = vacation.body ?? "";
}
/** @param {"signature" | "vacation"} kind @param {Record<string, unknown>} value */
const save = async (kind, value) => { const result = await store.saveConfig(kind, value); if (result.ok) hydrateFields(); };
const scheduledList = h("div", { class: "settings-list" });
effect(() => {
const pending = store.scheduledSends().filter((/** @type {any} */ item) => item.status === "pending");
replace(scheduledList, pending.length
? pending.map((/** @type {any} */ item) => h("p", null, h("b", null, item.subject || "(no subject)"), " ", h("code", null, `${item.to} · ${new Date(item.sendAt * 1000).toLocaleString()}`), " ", h("button", { class: "icon-button", "aria-label": "Cancel scheduled send", title: "Cancel", onclick: () => store.cancelScheduledSend(item.id) }, icon("Trash2", { size: 16 }))))
: h("p", { class: "muted" }, "No scheduled sends pending."));
});
const accountStatus = h("div");
effect(() => {
const kind = store.sync.errorKind();
const last = store.sync.lastSyncAt();
replace(accountStatus, kind
? h("div", { class: `account-status${kind === "auth-expired" ? " warn" : ""}` },
h("p", null, SYNC_ERROR_MESSAGES[/** @type {keyof typeof SYNC_ERROR_MESSAGES} */ (kind)] ?? ""),
kind === "auth-expired"
? h("button", { class: "secondary-action", onclick: () => store.reauthenticate() }, "Sign in again")
: h("button", { class: "secondary-action", onclick: () => store.sync.retryNow(() => store.loadMailbox(store.folder())) }, "Retry sync"))
: h("p", { class: "account-ok" }, `Connected. Last synced ${last ? new Date(last).toLocaleTimeString() : "—"}.`));
});
const appearance = h("div");
effect(() => {
const theme = store.preferences().theme ?? "light";
const shortcuts = store.preferences().shortcuts !== false;
replace(appearance, [
h("div", { class: "segmented", "aria-label": "Theme preference" },
["light", "dark"].map((choice) => h("button", { class: theme === choice ? "active" : null, "aria-pressed": String(theme === choice), onclick: () => store.setTheme(choice) }, choice === "light" ? "Light" : "Dark"))),
h("label", { class: "setting-copy" }, h("input", { type: "checkbox", checked: shortcuts, onchange: (/** @type {Event} */ event) => store.setShortcutsEnabled(/** @type {HTMLInputElement} */ (event.currentTarget).checked) }), " Keyboard shortcuts"),
]);
});
const contactsList = h("div", { class: "settings-list" });
effect(() => {
const contacts = store.contacts();
replace(contactsList, contacts.length
? contacts.map((/** @type {any} */ item) => h("p", null, h("b", null, item.name || item.email), h("code", null, item.email)))
: h("p", { class: "muted" }, "No contacts saved."));
});
const rulesList = h("div", { class: "settings-list" });
effect(() => {
const rules = store.rules();
replace(rulesList, rules.length
? rules.map((/** @type {any} */ item) => h("p", null, h("b", null, item.field), " contains ", h("code", null, item.contains), h("span", null, item.action)))
: h("p", { class: "muted" }, "No rules configured."));
});
const drawer = h("div", { class: "settings-drawer", "aria-label": "Webmail settings", role: "dialog", "aria-modal": "true" },
h("div", { class: "settings-head" }, h("span", null, icon("Settings2", { size: 19 }), " Settings"), h("button", { class: "icon-button", "aria-label": "Close settings", onclick: () => store.settingsOpen(false) }, icon("X", { size: 18 }))),
h("section", null, h("h2", null, "Scheduled sends"), h("p", { class: "setting-copy" }, "Messages queued to send later. Cancel any time before it fires."), scheduledList),
h("section", null, h("h2", null, "Account"), h("p", { class: "setting-copy" }, "Signed in as ", h("b", null, store.current), "."), accountStatus),
h("section", null, h("h2", null, "Appearance"), h("p", { class: "setting-copy" }, "Choose the contrast that is most comfortable for you."), appearance),
h("section", null, h("h2", null, "Contacts"),
h("div", { class: "stack-fields" }, contactName, contactEmail,
h("button", { class: "secondary-action", onclick: async () => { const result = await store.saveConfig("contact", { name: contactName.value, email: contactEmail.value }); if (result.ok) { contactName.value = ""; contactEmail.value = ""; } } }, icon("UserPlus", { size: 16 }), " Add contact")),
contactsList),
h("section", null, h("h2", null, "Signature"), signature, h("button", { class: "secondary-action", onclick: () => save("signature", { signature: signature.value }) }, "Save signature")),
h("section", null, h("h2", null, "Vacation reply"),
h("label", { class: "check" }, vacationEnabled, " ", h("span", null, "Send automatic replies")),
vacationSubject, vacationBody,
h("button", { class: "secondary-action", onclick: () => save("vacation", { enabled: vacationEnabled.checked, subject: vacationSubject.value, body: vacationBody.value }) }, "Save vacation reply")),
h("section", null, h("h2", null, "Inbox rules"),
h("div", { class: "rule-builder" },
choiceMenu(() => rule().field, RULE_FIELDS, (field) => rule({ ...rule(), field })),
ruleContains,
choiceMenu(() => rule().action, RULE_ACTIONS, (action) => rule({ ...rule(), action }))),
h("button", { class: "secondary-action", onclick: async () => { const result = await store.saveConfig("rule", { ...rule(), contains: ruleContains.value }); if (result.ok) ruleContains.value = ""; } }, "Add rule"),
h("button", { class: "subtle-button", onclick: () => store.applyRules() }, "Apply rules now"),
rulesList),
h("section", null, h("h2", null, "Import or export"), importText),
);
const overlay = h("div", { class: "drawer-overlay", role: "presentation", onclick: () => store.settingsOpen(false) });
let wasOpen = false;
effect(() => {
const open = store.settingsOpen();
if (open && !wasOpen) hydrateFields();
wasOpen = open;
replace(root, open ? [overlay, drawer] : null);
});
// Saved values arriving after the drawer opened (e.g. the first mailbox load) land in the fields too.
effect(() => { store.webmailState(); if (store.settingsOpen()) hydrateFields(); });
return { drawer, hydrateFields };
}