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 <a href="mailto:..."> 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.
This commit is contained in:
mAi
2026-05-25 13:30:32 +02:00
parent 0059e3f15b
commit 5589cbb477

View File

@@ -1,6 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n"; import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar"; import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast"; import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface User { interface User {
id: string; id: string;
@@ -341,28 +341,64 @@ function buildProjectFilter() {
function buildBroadcastButton() { function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap"); const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return; 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.innerHTML = "";
wrap.style.display = "none"; wrap.style.display = "none";
return; return;
} }
wrap.style.display = ""; wrap.style.display = "";
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
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 = ` wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn"> <button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span> ${label} ${counter}
</button> </button>
`; `;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick()); 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 = `
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
${label} ${counter}
</a>
`;
}
} }
function updateBroadcastButton() { function updateBroadcastButton() {
buildBroadcastButton(); buildBroadcastButton();
const recipients = displayedRecipients();
const countEl = document.getElementById("team-broadcast-count"); const countEl = document.getElementById("team-broadcast-count");
if (countEl) { if (countEl) countEl.textContent = String(recipients.length);
const n = displayedRecipients().length; const btn = document.getElementById("team-broadcast-btn");
countEl.textContent = String(n); if (!btn) return;
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null; if (btn.tagName === "BUTTON") {
if (btn) btn.disabled = n === 0; (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}", "{n}",
String(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
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
footer.innerHTML = ` footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span> <span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear"> <button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")} ${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button> </button>
<button type="button" class="btn-primary" id="team-selection-send"> ${sendAction}
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
</button>
`; `;
footer.style.display = ""; footer.style.display = "";
document.body.classList.add("team-has-selection"); document.body.classList.add("team-has-selection");
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
syncMasterCheckbox(); syncMasterCheckbox();
renderSelectionFooter(); renderSelectionFooter();
}); });
if (adminPath) {
document.getElementById("team-selection-send")?.addEventListener("click", () => { document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection(); onBroadcastFromSelection();
}); });
}
// Anchor path has no click handler — native href open is the action.
} }
// selectedRecipients maps the explicit selection Set into the // selectedRecipients maps the explicit selection Set into the