From 5589cbb47740958feb3d7d5f5d1a7da3b18cbf5f Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 13:30:32 +0200 Subject: [PATCH] mAi: #75 - team view mailto: link for non-admin members t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on /team (filter-bar + bottom selection footer) now branch on canBroadcast(): - Admin path keeps the in-app compose modal (POST /api/team/broadcast). - Non-admin path renders a native with the recipient list pre-filled, comma-joined and URL-encoded via buildMailtoHref (already exported from broadcast.ts). Filter-bar button used to hide for non-admins; it now shows as the mailto: anchor and its href refreshes on every filter change so the link always matches what's visible. Empty visible set disables the affordance visually (aria-disabled + pointer-events:none) so a click can't open an empty composer. Bottom selection footer mirrors the same shape. No new i18n keys, no backend changes, admin compose flow untouched. --- frontend/src/client/team.ts | 84 ++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/frontend/src/client/team.ts b/frontend/src/client/team.ts index 91abc0c..26e2ed1 100644 --- a/frontend/src/client/team.ts +++ b/frontend/src/client/team.ts @@ -1,6 +1,6 @@ import { initI18n, onLangChange, t, tDyn } from "./i18n"; import { initSidebar } from "./sidebar"; -import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast"; +import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast"; interface User { id: string; @@ -341,28 +341,64 @@ function buildProjectFilter() { function buildBroadcastButton() { const wrap = document.getElementById("team-broadcast-wrap"); if (!wrap) return; - if (!canBroadcast()) { + // Wait for /api/me so the affordance never flickers between admin (form) + // and non-admin (mailto) on initial paint. canBroadcast() already returns + // false when me is null but we'd briefly render the mailto anchor before + // the admin form, which is visually jarring. + if (!me) { wrap.innerHTML = ""; wrap.style.display = "none"; return; } wrap.style.display = ""; - wrap.innerHTML = ` - - `; - document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick()); + const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl"); + const counter = `0`; + if (canBroadcast()) { + // Admin path (global_admin or project-lead-of-selected): opens the + // in-app compose modal that POSTs to /api/team/broadcast. + wrap.innerHTML = ` + + `; + document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick()); + } else { + // Non-admin path (t-paliad-244): native mailto: anchor pre-filled with + // the current filter set. href is refreshed in updateBroadcastButton() + // whenever filters change so the link always reflects what's visible. + wrap.innerHTML = ` + + ${label} ${counter} + + `; + } } function updateBroadcastButton() { buildBroadcastButton(); + const recipients = displayedRecipients(); const countEl = document.getElementById("team-broadcast-count"); - if (countEl) { - const n = displayedRecipients().length; - countEl.textContent = String(n); - const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null; - if (btn) btn.disabled = n === 0; + if (countEl) countEl.textContent = String(recipients.length); + const btn = document.getElementById("team-broadcast-btn"); + if (!btn) return; + if (btn.tagName === "BUTTON") { + (btn as HTMLButtonElement).disabled = recipients.length === 0; + } else { + // Anchor (non-admin): regenerate the mailto: href against the current + // visible recipients, and disable the affordance when empty so a click + // doesn't open an empty mail composer. + const a = btn as HTMLAnchorElement; + if (recipients.length === 0) { + a.setAttribute("href", "mailto:"); + a.setAttribute("aria-disabled", "true"); + a.style.pointerEvents = "none"; + a.style.opacity = "0.5"; + } else { + a.setAttribute("href", buildMailtoHref(recipients)); + a.removeAttribute("aria-disabled"); + a.style.pointerEvents = ""; + a.style.opacity = ""; + } } } @@ -673,14 +709,21 @@ function renderSelectionFooter(): void { "{n}", String(n), ); + const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl"); + // t-paliad-244: mirror buildBroadcastButton() so the bottom send button + // behaves the same as the filter-bar one. Admin (canBroadcast) opens the + // compose modal; non-admin gets a native mailto: anchor pre-filled with + // the explicit selection. + const adminPath = canBroadcast(); + const sendAction = adminPath + ? `` + : `${sendLabel}`; footer.innerHTML = ` ${esc(countLabel)} - + ${sendAction} `; footer.style.display = ""; document.body.classList.add("team-has-selection"); @@ -691,9 +734,12 @@ function renderSelectionFooter(): void { syncMasterCheckbox(); renderSelectionFooter(); }); - document.getElementById("team-selection-send")?.addEventListener("click", () => { - onBroadcastFromSelection(); - }); + if (adminPath) { + document.getElementById("team-selection-send")?.addEventListener("click", () => { + onBroadcastFromSelection(); + }); + } + // Anchor path has no click handler — native href open is the action. } // selectedRecipients maps the explicit selection Set into the