generated from coulomb/repo-seed
- Move Interventions under Workstreams in the navigator - Add action-confirm.js: shared modal component for actions requiring a mandatory comment (survives live-poll re-renders, unlike inline DOM mutation) - Wire action-confirm into Interventions (Mark done) and Decisions (Resolve) - Fix Interventions completed section: fetch all tasks and filter client-side so resolved interventions (needs_human=false) still appear under Completed - Add docs/interventions.md help page with ? button on the h1 - Replace all hardcoded "Bernd" with "human" across dashboard src and docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
/**
|
|
* action-confirm — modal dialog that requires a non-empty comment before
|
|
* confirming an action (e.g. resolving a decision, marking an intervention done).
|
|
*
|
|
* Lives on document.body so it survives live-poll re-renders of the page content.
|
|
*
|
|
* Usage:
|
|
* import {openActionConfirm} from "./components/action-confirm.js";
|
|
*
|
|
* openActionConfirm({
|
|
* title: "Mark as Done", // modal header
|
|
* entityTitle: task.title, // shown as context below the header
|
|
* label: "Resolution comment", // textarea label
|
|
* placeholder: "What was done?", // textarea placeholder
|
|
* confirmLabel: "Mark Done", // confirm button text
|
|
* onConfirm: async (comment) => { ... }, // called with trimmed comment
|
|
* // onConfirm should throw (or return a rejected promise) on API error
|
|
* });
|
|
*/
|
|
|
|
const _STYLE_ID = "action-confirm-styles";
|
|
|
|
function _ensureStyles() {
|
|
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
|
const s = document.createElement("style");
|
|
s.id = _STYLE_ID;
|
|
s.textContent = `
|
|
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
|
.ac-backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
|
z-index: 9200; display: flex; align-items: center; justify-content: center;
|
|
animation: _ac-fade 0.15s ease;
|
|
}
|
|
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
|
|
|
|
/* ── Box ──────────────────────────────────────────────────────────────────── */
|
|
.ac-box {
|
|
width: min(480px, 92vw);
|
|
background: var(--theme-background, #fff);
|
|
border-radius: 12px;
|
|
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
|
display: flex; flex-direction: column; overflow: hidden;
|
|
animation: _ac-rise 0.15s ease;
|
|
}
|
|
@keyframes _ac-rise {
|
|
from { transform: translateY(12px); opacity: 0 }
|
|
to { transform: translateY(0); opacity: 1 }
|
|
}
|
|
|
|
/* ── Header ───────────────────────────────────────────────────────────────── */
|
|
.ac-header {
|
|
display: flex; align-items: flex-start; gap: 0.75rem;
|
|
padding: 0.85rem 1rem 0.75rem;
|
|
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
|
background: var(--theme-background-alt, #f7f7f7);
|
|
}
|
|
.ac-title {
|
|
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
|
|
color: var(--theme-foreground, #111);
|
|
}
|
|
.ac-close {
|
|
background: none; border: 1px solid transparent; cursor: pointer;
|
|
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
|
|
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
|
|
font-family: inherit; line-height: 1.3;
|
|
}
|
|
.ac-close:hover {
|
|
border-color: var(--theme-foreground-faint, #ccc);
|
|
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
|
}
|
|
|
|
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
|
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
|
|
|
.ac-entity-title {
|
|
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
|
|
background: var(--theme-background-alt, #f9f9f9);
|
|
border: 1px solid var(--theme-foreground-faint, #eee);
|
|
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.ac-label {
|
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
|
|
margin-bottom: 0.25rem; display: block;
|
|
}
|
|
|
|
.ac-textarea {
|
|
width: 100%; box-sizing: border-box;
|
|
min-height: 80px; resize: vertical;
|
|
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
|
|
padding: 0.5rem 0.65rem;
|
|
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
|
border-radius: 6px;
|
|
background: var(--theme-background, #fff);
|
|
color: var(--theme-foreground, #111);
|
|
transition: border-color 0.1s, box-shadow 0.1s;
|
|
}
|
|
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
|
|
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
|
|
|
|
.ac-error {
|
|
font-size: 0.8rem; color: #dc2626;
|
|
background: #fef2f2; border: 1px solid #fecaca;
|
|
border-radius: 6px; padding: 0.35rem 0.6rem;
|
|
}
|
|
|
|
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
|
.ac-footer {
|
|
display: flex; justify-content: flex-end; gap: 0.5rem;
|
|
padding: 0.65rem 1rem 0.9rem;
|
|
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
|
}
|
|
.ac-btn-cancel {
|
|
padding: 0.3rem 0.85rem; border-radius: 6px;
|
|
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
|
background: var(--theme-background, #fff);
|
|
color: var(--theme-foreground-muted, #555);
|
|
font-size: 0.85rem; font-family: inherit; cursor: pointer;
|
|
}
|
|
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
|
|
|
|
.ac-btn-confirm {
|
|
padding: 0.3rem 0.85rem; border-radius: 6px;
|
|
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
|
|
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
|
|
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
`;
|
|
document.head.append(s);
|
|
}
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} opts.title Modal header text
|
|
* @param {string} [opts.entityTitle] Optional context shown below the header
|
|
* @param {string} opts.label Textarea label
|
|
* @param {string} [opts.placeholder] Textarea placeholder
|
|
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
|
|
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
|
|
*/
|
|
export function openActionConfirm({
|
|
title,
|
|
entityTitle,
|
|
label,
|
|
placeholder = "Add a comment…",
|
|
confirmLabel = "Confirm",
|
|
onConfirm,
|
|
}) {
|
|
_ensureStyles();
|
|
document.getElementById("_ac-root")?.remove();
|
|
|
|
const root = document.createElement("div");
|
|
root.id = "_ac-root";
|
|
root.className = "ac-backdrop";
|
|
root.setAttribute("role", "dialog");
|
|
root.setAttribute("aria-modal", "true");
|
|
root.setAttribute("aria-label", title);
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────────
|
|
const header = document.createElement("div");
|
|
header.className = "ac-header";
|
|
const titleEl = document.createElement("div");
|
|
titleEl.className = "ac-title";
|
|
titleEl.textContent = title;
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.className = "ac-close";
|
|
closeBtn.textContent = "✕";
|
|
closeBtn.setAttribute("aria-label", "Cancel");
|
|
header.append(titleEl, closeBtn);
|
|
|
|
// ── Body ──────────────────────────────────────────────────────────────────
|
|
const body = document.createElement("div");
|
|
body.className = "ac-body";
|
|
|
|
if (entityTitle) {
|
|
const ctx = document.createElement("div");
|
|
ctx.className = "ac-entity-title";
|
|
ctx.textContent = entityTitle;
|
|
body.append(ctx);
|
|
}
|
|
|
|
const labelEl = document.createElement("label");
|
|
labelEl.className = "ac-label";
|
|
labelEl.textContent = label;
|
|
|
|
const textarea = document.createElement("textarea");
|
|
textarea.className = "ac-textarea";
|
|
textarea.placeholder = placeholder;
|
|
textarea.rows = 3;
|
|
labelEl.append(textarea); // make label clickable
|
|
|
|
const fieldWrap = document.createElement("div");
|
|
fieldWrap.append(labelEl);
|
|
body.append(fieldWrap);
|
|
|
|
const errorEl = document.createElement("div");
|
|
errorEl.className = "ac-error";
|
|
errorEl.style.display = "none";
|
|
body.append(errorEl);
|
|
|
|
// ── Footer ────────────────────────────────────────────────────────────────
|
|
const footer = document.createElement("div");
|
|
footer.className = "ac-footer";
|
|
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.className = "ac-btn-cancel";
|
|
cancelBtn.textContent = "Cancel";
|
|
|
|
const confirmBtn = document.createElement("button");
|
|
confirmBtn.className = "ac-btn-confirm";
|
|
confirmBtn.textContent = confirmLabel;
|
|
|
|
footer.append(cancelBtn, confirmBtn);
|
|
|
|
// ── Assemble ──────────────────────────────────────────────────────────────
|
|
const box = document.createElement("div");
|
|
box.className = "ac-box";
|
|
box.append(header, body, footer);
|
|
root.append(box);
|
|
document.body.append(root);
|
|
|
|
// Focus the textarea after animation
|
|
setTimeout(() => textarea.focus(), 50);
|
|
|
|
// ── Behaviour ─────────────────────────────────────────────────────────────
|
|
const close = () => root.remove();
|
|
|
|
cancelBtn.addEventListener("click", close);
|
|
closeBtn.addEventListener("click", close);
|
|
root.addEventListener("click", e => { if (e.target === root) close(); });
|
|
|
|
const onKey = e => {
|
|
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
textarea.addEventListener("input", () => {
|
|
textarea.classList.remove("ac-invalid");
|
|
errorEl.style.display = "none";
|
|
});
|
|
|
|
confirmBtn.addEventListener("click", async () => {
|
|
const comment = textarea.value.trim();
|
|
if (!comment) {
|
|
textarea.classList.add("ac-invalid");
|
|
textarea.focus();
|
|
return;
|
|
}
|
|
|
|
confirmBtn.disabled = true;
|
|
cancelBtn.disabled = true;
|
|
confirmBtn.textContent = "…";
|
|
errorEl.style.display = "none";
|
|
|
|
try {
|
|
await onConfirm(comment);
|
|
close();
|
|
} catch (err) {
|
|
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
|
|
errorEl.style.display = "";
|
|
confirmBtn.disabled = false;
|
|
cancelBtn.disabled = false;
|
|
confirmBtn.textContent = confirmLabel;
|
|
}
|
|
});
|
|
}
|