AkurAI Build
Menu

Public repository / master

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 };
}