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