Labels in Mail UI (chips + Actions + sidebar), plain-text Lato, calendar-panel polish
Labels:
- Moved labelling out of the "+label" message slot into Mail: label chips on the
thread subject bar (removable ✕) and before list subjects, and a Labels submenu
in the per-message Actions menu (multi-select checkboxes + "Create new…").
Per-message storage kept; the reading pane writes a thread-union aggregate so the
list (no message ids) can show chips.
- Sidebar LABELS section rewritten: aligned monocolor tag rows, collapsible
(collapsed by default), inline "+" create popover, hover "−" delete with a confirm
popover, and click-to-filter the mail list by label.
- Backend: batch message/thread label reads, thread aggregate, create + delete
(delete unhooks from all mail), and GET /labels/{name}/threads. Core GET /threads
gained an `ids` param to power the label view. Removed the plugin-pages list from
the Mail sidebar.
Plain-text mail now renders in Lato (self-hosted at /fonts, @font-face'd into the
sandboxed message iframe) instead of the monospace <pre> default.
Calendar right-panel (embed mode): restored the "…" view-options menu, pinned the
toolbar + mini-calendar (only the agenda scrolls), plain "All calendars" button
(inline resets), and the event popover now closes on any outside click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -1,20 +1,129 @@
|
||||
// labels · sidebar.section slot (SPEC §11.3). Lists the user's known labels;
|
||||
// refreshes when the message.actions slot signals a change (same-document event).
|
||||
// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md).
|
||||
// labels · sidebar.section slot (SPEC §11.3). A collapsible "LABELS" section that
|
||||
// lines up with the folder rows above: monocolor tag icons, a + to create (inline
|
||||
// popover), a hover "−" per label to delete (removed from all mail after a confirm),
|
||||
// and click-to-filter the mail list. Refreshes on `webmail:labels-changed`, which
|
||||
// this bundle and Mail's Actions menu both dispatch. Themed via rgb(var(--app-*)).
|
||||
|
||||
const TAG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20.6 13.4l-7.1 7.1a2 2 0 0 1-2.83 0l-6.17-6.17A2 2 0 0 1 3 12.93V5a2 2 0 0 1 2-2h7.93a2 2 0 0 1 1.4.59l6.17 6.17a2 2 0 0 1 0 2.83z"/><circle cx="7.5" cy="7.5" r="1.2" fill="currentColor" stroke="none"/></svg>`;
|
||||
const chev = (open) => `<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(${open ? 0 : -90}deg)"><path d="M4 6l4 4 4-4"/></svg>`;
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById("lb-styles")) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = "lb-styles";
|
||||
s.textContent = `
|
||||
.lb-sec{position:relative;padding:4px 0 0}
|
||||
.lb-head{display:flex;align-items:center;gap:5px;padding:10px 8px 2px 12px}
|
||||
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
|
||||
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
|
||||
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.lb-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}
|
||||
.lb-row{position:relative;display:flex;align-items:center}
|
||||
.lb-item{flex:1;min-width:0;display:flex;align-items:center;gap:10px;text-align:left;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:6px 12px;border-radius:8px;font:inherit;font-size:15px}
|
||||
.lb-item:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.lb-item .ic{color:rgb(var(--app-faint));flex:0 0 auto;display:inline-flex}
|
||||
.lb-item .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.lb-del{position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:5px;background:rgb(var(--app-hover));color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1}
|
||||
.lb-row:hover .lb-del{opacity:1}
|
||||
.lb-del:hover{background:rgb(var(--app-active));color:#ef4444}
|
||||
.lb-empty{color:rgb(var(--app-faint));padding:5px 12px;font-size:13px}
|
||||
.lb-ovl{position:fixed;inset:0;z-index:60}
|
||||
.lb-pop{position:absolute;z-index:61;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.4);padding:10px;width:210px}
|
||||
.lb-popmsg{font-size:13px;color:rgb(var(--app-fg));margin-bottom:8px}
|
||||
.lb-popinput{width:100%;box-sizing:border-box;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:6px 8px;color:rgb(var(--app-fg));font:inherit;font-size:13px;outline:none}
|
||||
.lb-popinput:focus{border-color:rgb(var(--app-accent))}
|
||||
.lb-popbtns{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}
|
||||
.lb-btn{border:0;border-radius:6px;padding:5px 12px;cursor:pointer;font:inherit;font-size:13px;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.lb-btn-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));font-weight:600}
|
||||
.lb-btn-danger{background:#ef4444;color:#fff;font-weight:600}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
export default function mount(el, ctx) {
|
||||
el.innerHTML = `
|
||||
<div style="padding:8px 12px 0">
|
||||
<div class="wm-faint" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding-bottom:4px">Labels</div>
|
||||
<ul data-list class="wm-list"></ul>
|
||||
injectStyles();
|
||||
let collapsed = localStorage.getItem("labels.collapsed") !== "0"; // collapsed by default
|
||||
let labels = [];
|
||||
let adding = null; // null | { x, y }
|
||||
let deleting = null; // null | { name, x, y }
|
||||
|
||||
function render() {
|
||||
el.innerHTML = `<div class="lb-sec">
|
||||
<div class="lb-head">
|
||||
<button class="lb-headbtn" data-act="toggle">Labels ${chev(!collapsed)}</button>
|
||||
<button class="lb-add" data-act="add" title="Create label">+</button>
|
||||
</div>
|
||||
${collapsed ? "" : `<ul class="lb-list">${
|
||||
labels.length
|
||||
? labels.map((name) => `<li class="lb-row">
|
||||
<button class="lb-item" data-label="${esc(name)}"><span class="ic">${TAG}</span><span class="nm">${esc(name)}</span></button>
|
||||
<button class="lb-del" data-del="${esc(name)}" title="Delete label">−</button>
|
||||
</li>`).join("")
|
||||
: `<li class="lb-empty">No labels yet</li>`
|
||||
}</ul>`}
|
||||
${adding ? `<div class="lb-ovl" data-act="popclose"></div><div class="lb-pop" style="position:fixed;left:${Math.max(8, Math.min(adding.x, window.innerWidth - 226))}px;top:${adding.y + 6}px">
|
||||
<input class="lb-popinput" data-newlabel placeholder="Label name"/>
|
||||
<div class="lb-popbtns"><button class="lb-btn" data-act="add-cancel">Cancel</button><button class="lb-btn lb-btn-primary" data-act="add-ok">Create</button></div>
|
||||
</div>` : ""}
|
||||
${deleting ? `<div class="lb-ovl" data-act="popclose"></div><div class="lb-pop" style="left:${Math.max(8, Math.min(deleting.x, window.innerWidth - 226))}px;top:${deleting.y + 6}px;position:fixed">
|
||||
<div class="lb-popmsg">Delete “${esc(deleting.name)}”? It will be removed from all mail.</div>
|
||||
<div class="lb-popbtns"><button class="lb-btn" data-act="del-cancel">Cancel</button><button class="lb-btn lb-btn-danger" data-act="del-ok">Delete</button></div>
|
||||
</div>` : ""}
|
||||
</div>`;
|
||||
const list = el.querySelector("[data-list]");
|
||||
wire();
|
||||
if (adding) { const inp = el.querySelector("[data-newlabel]"); if (inp) inp.focus(); }
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||
|
||||
act("toggle", () => {
|
||||
collapsed = !collapsed;
|
||||
try { localStorage.setItem("labels.collapsed", collapsed ? "1" : "0"); } catch { /* ignore */ }
|
||||
render();
|
||||
});
|
||||
act("add", (e) => { const r = e.currentTarget.getBoundingClientRect(); adding = { x: r.right - 210, y: r.bottom }; deleting = null; render(); });
|
||||
act("add-cancel", () => { adding = null; render(); });
|
||||
act("popclose", () => { adding = null; deleting = null; render(); });
|
||||
act("add-ok", createLabel);
|
||||
on("[data-newlabel]", "keydown", (e) => {
|
||||
if (e.key === "Enter") createLabel();
|
||||
else if (e.key === "Escape") { adding = null; render(); }
|
||||
});
|
||||
on("[data-label]", "click", (e) => ctx.navigate(`/mail?label=${encodeURIComponent(e.currentTarget.getAttribute("data-label"))}`));
|
||||
on("[data-del]", "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
deleting = { name: e.currentTarget.getAttribute("data-del"), x: r.right - 210, y: r.bottom };
|
||||
adding = false;
|
||||
render();
|
||||
});
|
||||
act("del-cancel", () => { deleting = null; render(); });
|
||||
act("del-ok", deleteLabel);
|
||||
}
|
||||
|
||||
async function createLabel() {
|
||||
const inp = el.querySelector("[data-newlabel]");
|
||||
const name = inp ? inp.value.trim() : "";
|
||||
if (!name) { adding = null; render(); return; }
|
||||
await ctx.api.pluginFetch("/labels", { method: "POST", body: JSON.stringify({ name }) });
|
||||
adding = null;
|
||||
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||
}
|
||||
|
||||
async function deleteLabel() {
|
||||
const name = deleting && deleting.name;
|
||||
deleting = null;
|
||||
if (!name) { render(); return; }
|
||||
await ctx.api.pluginFetch(`/labels/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const labels = await ctx.api.pluginFetch("/labels");
|
||||
list.innerHTML = labels.length
|
||||
? labels.map((name) => `<li>🏷 ${esc(name)}</li>`).join("")
|
||||
: `<li class="wm-faint">No labels yet</li>`;
|
||||
try { labels = await ctx.api.pluginFetch("/labels"); } catch { labels = []; }
|
||||
render();
|
||||
}
|
||||
|
||||
refresh();
|
||||
@@ -23,8 +132,4 @@ export default function mount(el, ctx) {
|
||||
window.removeEventListener("webmail:labels-changed", refresh);
|
||||
el.innerHTML = "";
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user