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:
2026-08-03 20:53:35 +02:00
co-authored by Claude Opus 4.8
parent 1d1a621ce2
commit 0a478a6b26
16 changed files with 574 additions and 75 deletions
@@ -249,13 +249,18 @@ const CSS = `
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
.cal-panelcal{position:relative}
.cal-panelmenu{position:relative}
.cal-panelcalbtn{display:flex;align-items:center;gap:6px;background:none;border:0;outline:none;color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px}
.cal-panelcalbtn:hover{color:rgb(var(--app-muted))}
.cal-ddsec{padding:5px 10px 2px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
.cal-ddsep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
.cal-embed .cal-searchbox{margin:0}
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
.cal-embed .cal-pop{position:fixed}
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex}
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex;outline:none}
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
@@ -385,12 +390,16 @@ const CSS = `
`;
export default function mount(el, ctx, props) {
if (!document.getElementById("cal-styles")) {
const style = document.createElement("style");
style.id = "cal-styles";
style.textContent = CSS;
document.head.appendChild(style);
// Always (re)write the stylesheet so a newer bundle version's CSS wins — the
// singleton element would otherwise keep a stale copy from an earlier mount,
// leaving new classes (e.g. .cal-panelcalbtn) unstyled = default button frame.
let calStyle = document.getElementById("cal-styles");
if (!calStyle) {
calStyle = document.createElement("style");
calStyle.id = "cal-styles";
document.head.appendChild(calStyle);
}
calStyle.textContent = CSS;
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
// agenda, mini-month, event popover, event form and search results verbatim, so
@@ -417,6 +426,9 @@ export default function mount(el, ctx, props) {
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
searchOpen: false, // embed: search box revealed
panelCalOpen: false, // embed: "All calendars" dropdown open
panelMenuOpen: false, // embed: "..." view-options menu open
panelMini: "month", // embed mini calendar: month | week
panelView: "agenda", // embed agenda: day | 3days | agenda
me: "",
quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
@@ -878,8 +890,9 @@ export default function mount(el, ctx, props) {
function renderEmbed() {
el.innerHTML = `<div class="cal-app cal-embed">
${panelBar()}
${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`}
<div class="cal-embed-body">
${state.search ? searchPanelHtml() : `<div class="cal-embed-mini">${miniMonth()}</div>${agenda()}`}
${state.search ? searchPanelHtml() : agenda()}
</div>
${panelFoot()}
${state.composing ? formHtml() : ""}
@@ -895,12 +908,28 @@ export default function mount(el, ctx, props) {
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
? state.calendars.find((c) => !state.hidden.has(c.id))
: null;
// Inline resets guarantee no default-button frame/background/focus-ring even if
// an older injected stylesheet is missing these classes.
const plain = "border:0;background:none;outline:none;box-shadow:none";
return `<div class="cal-panelbar">
<button class="cal-iconbtn" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
<button class="cal-iconbtn" style="${plain}" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" style="${plain}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
<div class="cal-dd cal-panelmenu">
<button class="cal-iconbtn" style="${plain}" data-act="panel-menu" title="Options"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></button>
${state.panelMenuOpen ? `<div class="cal-ddpop" style="left:0;min-width:170px">
<div class="cal-ddsec">Mini calendar</div>
<button data-panelmini="week">${state.panelMini === "week" ? "✓ " : "  "}Week</button>
<button data-panelmini="month">${state.panelMini === "month" ? "✓ " : "  "}Month</button>
<div class="cal-ddsep"></div>
<div class="cal-ddsec">View</div>
<button data-panelview="day">${state.panelView === "day" ? "✓ " : "  "}Day</button>
<button data-panelview="3days">${state.panelView === "3days" ? "✓ " : "  "}3 days</button>
<button data-panelview="agenda">${state.panelView === "agenda" ? "✓ " : "  "}Agenda</button>
</div>` : ""}
</div>
<div style="flex:1"></div>
<div class="cal-dd cal-panelcal">
<button class="cal-iconbtn" data-act="panelcal" style="width:auto;gap:6px;padding:6px 8px;font-size:13px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="cal-panelcalbtn" style="display:flex;align-items:center;gap:6px;${plain};color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px" data-act="panelcal"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
@@ -1003,11 +1032,14 @@ export default function mount(el, ctx, props) {
function miniMonth() {
const m = state.mini;
const gs = startOfWeek(startOfMonth(m));
// Embed "Mini calendar" option: Week shows just the anchor's week row.
const week = state.embed && state.panelMini === "week";
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
const cellCount = week ? 7 : 42;
let cells = "";
for (let i = 0; i < 42; i++) {
for (let i = 0; i < cellCount; i++) {
const d = addDays(gs, i);
const cls = [d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
}
return `<div class="cal-mini">
@@ -1123,7 +1155,7 @@ export default function mount(el, ctx, props) {
const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`;
const left = Math.min(rect.right + 8, window.innerWidth - 302);
const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230);
return `<div class="cal-popoverlay" style="position:absolute;inset:0;z-index:59" data-act="pop-close"></div>
return `<div class="cal-popoverlay" style="position:fixed;inset:0;z-index:59" data-act="pop-close"></div>
<div class="cal-pop" style="left:${left}px;top:${top}px">
<div class="body">
<div class="cap">${esc(cal ? cal.name : "")}</div>
@@ -1258,15 +1290,18 @@ export default function mount(el, ctx, props) {
}
function agenda() {
const start = startOfMonth(state.anchor);
// Embed "View" option: Agenda = the month; Day/3 days = a short run from the anchor.
const short = state.embed && state.panelView !== "agenda";
const count = short ? (state.panelView === "day" ? 1 : 3) : 30;
const start = short ? startOfDay(state.anchor) : startOfMonth(state.anchor);
const evs = visibleEvents();
const rightAction = state.embed
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
for (let i = 0; i < 30; i++) {
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
if (d.getMonth() !== start.getMonth()) break;
if (!short && d.getMonth() !== start.getMonth()) break;
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
html += `<div class="day"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
@@ -1292,8 +1327,11 @@ export default function mount(el, ctx, props) {
state.panelCalOpen = false; render();
});
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
if (state.panelCalOpen) {
setTimeout(() => { const close = () => { state.panelCalOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
act("panel-menu", (e) => { e.stopPropagation(); state.panelMenuOpen = !state.panelMenuOpen; render(); });
on("[data-panelmini]", "click", (e) => { e.stopPropagation(); state.panelMini = e.currentTarget.getAttribute("data-panelmini"); state.panelMenuOpen = false; render(); });
on("[data-panelview]", "click", (e) => { e.stopPropagation(); state.panelView = e.currentTarget.getAttribute("data-panelview"); state.panelMenuOpen = false; render(); });
if (state.panelCalOpen || state.panelMenuOpen) {
setTimeout(() => { const close = () => { state.panelCalOpen = false; state.panelMenuOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
}
// --- search ---
on(".cal-search", "input", (e) => {
@@ -1,8 +1,9 @@
"""``labels`` — per-message labels, built entirely on slots + KV (SPEC §11, §12 M3).
No plugin tables and no core diffs: labels live in the namespaced KV store (keyed
per user, so isolation is by construction), surfaced through a ``message.actions``
slot (add/remove on a message) and a ``sidebar.section`` slot (list all labels).
per user, so isolation is by construction). The labelling UI now lives in Mail's
own Actions menu + label chips (which read the plugin's API directly); only the
``sidebar.section`` slot (list all labels) remains as a plugin surface.
"""
from __future__ import annotations
@@ -16,7 +17,6 @@ plugin = Plugin(
router=router,
ui_manifest=UiManifest(
slots=[
Slot(name="message.actions", bundle="/api/plugins/labels/ui/actions.js"),
Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"),
],
),
@@ -27,6 +27,23 @@ class LabelsIn(BaseModel):
labels: list[str]
class IdsIn(BaseModel):
ids: list[str]
class NameIn(BaseModel):
name: str
async def _remember(kv: object, labels: list[str]) -> None:
"""Merge ``labels`` into the user's known-label set."""
known = set(await kv.get(_ALL_KEY, [])) # type: ignore[attr-defined]
if not labels or set(labels) <= known:
return
known.update(labels)
await kv.set(_ALL_KEY, sorted(known)) # type: ignore[attr-defined]
@router.get("/message/{message_id}/labels", response_model=list[str])
async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]:
return await _SDK.kv(user_id).get(message_id, [])
@@ -37,17 +54,77 @@ async def set_labels(message_id: str, body: LabelsIn, user_id: CurrentUserId) ->
labels = sorted({name.strip() for name in body.labels if name.strip()})
kv = _SDK.kv(user_id)
await kv.set(message_id, labels)
known = set(await kv.get(_ALL_KEY, []))
known.update(labels)
await kv.set(_ALL_KEY, sorted(known))
await _remember(kv, labels)
return {"status": "ok", "labels": labels}
@router.post("/messages/labels")
async def batch_message_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
"""Labels for a set of messages (the reading pane aggregates the thread union)."""
kv = _SDK.kv(user_id)
return {mid: await kv.get(mid, []) for mid in body.ids}
@router.put("/thread/{thread_id}/labels", dependencies=[Depends(require_csrf)])
async def set_thread_labels(
thread_id: str, body: LabelsIn, user_id: CurrentUserId
) -> dict[str, object]:
"""The thread's aggregated labels (union of its messages), written by the reading
pane so the message list — which has no message ids — can display them."""
labels = sorted({name.strip() for name in body.labels if name.strip()})
kv = _SDK.kv(user_id)
await kv.set(f"t:{thread_id}", labels)
await _remember(kv, labels)
return {"status": "ok", "labels": labels}
@router.post("/threads/labels")
async def batch_thread_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
kv = _SDK.kv(user_id)
out = {tid: await kv.get(f"t:{tid}", []) for tid in body.ids}
return {tid: labels for tid, labels in out.items() if labels}
@router.get("/labels", response_model=list[str])
async def all_labels(user_id: CurrentUserId) -> list[str]:
return await _SDK.kv(user_id).get(_ALL_KEY, [])
@router.get("/labels/{name}/threads", response_model=list[str])
async def label_threads(name: str, user_id: CurrentUserId) -> list[str]:
"""Thread ids whose aggregate labels include ``name`` — powers the label view
in the mail list. Scans the per-thread label keys (``t:<thread_id>``)."""
items = await _SDK.kv(user_id).items()
return [
key[2:]
for key, labels in items.items()
if key.startswith("t:") and isinstance(labels, list) and name in labels
]
@router.post("/labels", dependencies=[Depends(require_csrf)])
async def create_label(body: NameIn, user_id: CurrentUserId) -> list[str]:
name = body.name.strip()
kv = _SDK.kv(user_id)
if name:
await _remember(kv, [name])
return await kv.get(_ALL_KEY, [])
@router.delete("/labels/{name}", dependencies=[Depends(require_csrf)])
async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
"""Delete a label everywhere: drop it from the known set and unhook it from every
message (``<message_id>``) and thread (``t:<thread_id>``) it was attached to."""
kv = _SDK.kv(user_id)
for key, labels in (await kv.items()).items():
if key == _ALL_KEY or not isinstance(labels, list) or name not in labels:
continue
await kv.set(key, [n for n in labels if n != name])
known = [n for n in await kv.get(_ALL_KEY, []) if n != name]
await kv.set(_ALL_KEY, known)
return known
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename not in _BUNDLES:
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}