ghpsolutions/quadro
Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT
A remix template · 0 remixes · Remix ghpsolutions/quadro
app/boards/doors/static/app.js
"use strict";
// Quadro web page. Plain JavaScript, no build step: it talks to the same /api use cases the
// MCP tools call.
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const LABEL_COLORS = ["slate", "red", "amber", "green", "teal", "blue", "violet", "pink"];
const AVATAR_COLORS = ["#2f5d50", "#7b5ea7", "#b4412f", "#2d4f86", "#8d5a10", "#3e6b2f", "#8a2f58", "#4a4843"];
const state = { me: null, boards: [], board: null, view: null, filter: "all", query: "", card: null, drag: null };
const hash = (s) => [...String(s)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
const labelClass = (name) => "l-" + LABEL_COLORS[hash(name.toLowerCase()) % LABEL_COLORS.length];
const initials = (email) => { const n = String(email).split("@")[0].split(/[._-]+/).filter(Boolean); return ((n[0] || "?")[0] + (n[1] ? n[1][0] : (n[0] || "").slice(1, 2))).toUpperCase(); };
const avatar = (email, title = email) => email ? `<span class="avatar" title="${esc(title)}" style="background:${AVATAR_COLORS[hash(email) % AVATAR_COLORS.length]}">${esc(initials(email))}</span>` : "";
const canEdit = () => state.board && state.board.role !== "viewer";
const ICON = {
clock: '<svg class="i" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
chat: '<svg class="i" viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.6A8 8 0 1 1 21 12z"/></svg>',
check: '<svg class="i" viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
lock: '<svg class="i" viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
text: '<svg class="i" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>',
};
// ---------------------------------------------------------------- api
async function api(method, path, body) {
const opts = { method, headers: { "content-type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (res.status === 401) { location.href = "/.dodil/auth/login?return_to=" + encodeURIComponent(location.pathname + location.hash); throw new Error("sign in"); }
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = typeof data.detail === "string" ? data.detail : "Something went wrong";
const err = new Error(msg); err.status = res.status; throw err;
}
return data;
}
function toast(msg, bad = false) {
const t = document.createElement("div");
t.className = "toast" + (bad ? " bad" : "");
t.textContent = msg;
$("#toasts").append(t);
setTimeout(() => t.remove(), bad ? 5200 : 2600);
}
async function attempt(fn, okMsg) {
try { const r = await fn(); if (okMsg) toast(okMsg); return r; }
catch (e) { if (e.message !== "sign in") toast(e.message, true); if (e.status === 409) await refresh(); return null; }
}
// ---------------------------------------------------------------- dates
const today = () => new Date().toISOString().slice(0, 10);
function dueText(d) {
if (!d) return "";
const days = Math.round((Date.parse(d) - Date.parse(today())) / 864e5);
if (days === 0) return "Today";
if (days === 1) return "Tomorrow";
if (days === -1) return "Yesterday";
const dt = new Date(d + "T00:00:00");
return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", ...(dt.getFullYear() !== new Date().getFullYear() ? { year: "numeric" } : {}) });
}
function dueClass(c) { if (!c.due_date || c.done) return ""; if (c.overdue) return "late"; const d = (Date.parse(c.due_date) - Date.parse(today())) / 864e5; return d <= 2 ? "soon" : ""; }
function ago(iso) {
const s = (Date.now() - Date.parse(iso)) / 1000;
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
// ---------------------------------------------------------------- routing
function go(hashValue) { if (location.hash !== hashValue) location.hash = hashValue; else route(); }
async function route() {
const h = location.hash.slice(1);
closeDrawer(true);
if (h.startsWith("board/")) await openBoard(decodeURIComponent(h.slice(6)));
else if (h.startsWith("card/")) { const id = decodeURIComponent(h.slice(5)); await openBoard(id.split("-")[0]); await openCard(id); }
else if (h === "overdue") await showOverdue();
else if (h.startsWith("search/")) await showSearch(decodeURIComponent(h.slice(7)));
else if (h === "plate" || !state.boards.length) await showPlate();
else go("#board/" + state.boards[0].id);
markNav();
}
function markNav() {
const h = location.hash.slice(1);
$$(".nav").forEach((b) => b.classList.toggle("on", b.dataset.view === h || ("board/" + b.dataset.board) === h));
}
// ---------------------------------------------------------------- sidebar
async function loadBoards() {
state.boards = await api("GET", "/api/boards");
$("#board-nav").innerHTML = state.boards.map((b) =>
`<button class="nav" data-board="${esc(b.id)}"><span>${esc(b.title)}</span><span class="key">${esc(b.key)}</span></button>`).join("")
|| `<p class="empty" style="padding:8px 10px;text-align:left">No boards yet.</p>`;
$$("#board-nav .nav").forEach((b) => b.onclick = () => go("#board/" + b.dataset.board));
markNav();
api("GET", "/api/overdue").then((list) => {
const el = $("#overdue-count"); el.hidden = !list.length; el.textContent = list.length;
}).catch(() => {});
}
// ---------------------------------------------------------------- board view
async function openBoard(id) {
state.view = "board";
const board = await attempt(() => api("GET", "/api/boards/" + encodeURIComponent(id)));
if (!board) { await showPlate(); return; }
state.board = board;
renderBoardTop();
renderBoard();
}
async function refresh() {
if (state.view === "board" && state.board) {
state.board = await api("GET", "/api/boards/" + state.board.id);
renderBoard();
renderBoardTop();
}
if (state.card) await openCard(state.card.id, true);
}
function renderBoardTop() {
const b = state.board;
$("#top").innerHTML = `
<h1>${esc(b.title)}</h1><span class="key">${esc(b.key)}</span>
<div class="seg" role="group" aria-label="Filter">
${[["all", "All"], ["mine", "Mine"], ["overdue", "Overdue"], ["blocked", "Blocked"]].map(([k, v]) => `<button data-f="${k}" class="${state.filter === k ? "on" : ""}">${v}</button>`).join("")}
</div>
<span class="grow"></span>
<input class="search" id="board-search" placeholder="Filter cards…" value="${esc(state.query)}" aria-label="Filter cards">
<button class="people" id="people-btn" title="Members" style="border:0;background:none;padding:0">${b.members.slice(0, 5).map((m) => avatar(m.email, `${m.email} · ${m.role}`)).join("")}</button>
<button class="btn" id="summary-btn">Daily summary</button>
<button class="btn ghost" id="activity-btn">History</button>`;
$$(".seg button", $("#top")).forEach((btn) => btn.onclick = () => { state.filter = btn.dataset.f; renderBoardTop(); renderBoard(); });
$("#board-search").oninput = (e) => { state.query = e.target.value; renderBoard(); };
$("#board-search").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) go("#search/" + encodeURIComponent(e.target.value.trim())); };
$("#people-btn").onclick = membersDialog;
$("#summary-btn").onclick = summaryDialog;
$("#activity-btn").onclick = activityDrawer;
}
function matches(c) {
if (state.filter === "mine" && c.assignee !== state.me) return false;
if (state.filter === "overdue" && !c.overdue) return false;
if (state.filter === "blocked" && !c.blocked) return false;
const q = state.query.trim().toLowerCase();
if (q && !(c.title + " " + c.description + " " + c.labels.join(" ") + " " + c.ref + " " + (c.assignee || "")).toLowerCase().includes(q)) return false;
return true;
}
function cardHtml(c) {
const meta = [`<span class="ref">${esc(c.ref)}</span>`];
if (c.due_date) meta.push(`<span class="flag due ${dueClass(c)}" title="Due ${esc(c.due_date)}">${ICON.clock}${esc(dueText(c.due_date))}</span>`);
if (c.checklist.total) meta.push(`<span class="flag" title="Checklist">${ICON.check}${c.checklist.done}/${c.checklist.total}</span>`);
if (c.comments) meta.push(`<span class="flag" title="Comments">${ICON.chat}${c.comments}</span>`);
if (c.blocked) meta.push(`<span class="flag blocked" title="Waiting on another card">${ICON.lock}Blocked</span>`);
else if (c.description && !c.checklist.total) meta.push(`<span class="flag" title="Has a description">${ICON.text}</span>`);
if (c.assignee) meta.push(avatar(c.assignee));
return `<article class="card${c.overdue ? " overdue" : ""}${c.done ? " is-done" : ""}" data-id="${esc(c.id)}" draggable="${canEdit()}" tabindex="0">
${c.labels.length ? `<div class="labels">${c.labels.map((l) => `<span class="label ${labelClass(l)}">${esc(l)}</span>`).join("")}</div>` : ""}
<div class="title">${esc(c.title)}</div>
<div class="meta">${meta.join("")}</div></article>`;
}
function renderBoard() {
const b = state.board;
const view = $("#view");
const notice = b.role === "viewer" ? `<div class="notice">You have view access to this board. Ask ${esc(b.members[0].email)} for member access to make changes.</div>` : "";
view.innerHTML = notice + `<div class="board">${b.lists.map((l) => {
const cards = l.cards.filter(matches);
return `<section class="col" data-list="${esc(l.id)}">
<header class="col-head"><h2>${esc(l.name)}</h2><span class="count">${cards.length}${cards.length !== l.cards.length ? " of " + l.cards.length : ""}</span>
${l.done ? `<span class="done-mark" title="Cards here count as done">Done</span>` : ""}
${canEdit() ? `<button class="icon-btn" data-menu="${esc(l.id)}" aria-label="List options">⋯</button>` : ""}</header>
<div class="cards" data-list="${esc(l.id)}">${cards.map(cardHtml).join("")}</div>
${canEdit() ? `<button class="add-card" data-add="${esc(l.id)}">+ Add a card</button>` : ""}
</section>`;
}).join("")}
${canEdit() ? `<div class="new-col"><button class="btn ghost" id="add-list">+ Add list</button></div>` : ""}</div>`;
$$(".card", view).forEach((el) => {
el.onclick = () => openCard(el.dataset.id);
el.onkeydown = (e) => { if (e.key === "Enter") openCard(el.dataset.id); };
});
$$("[data-add]", view).forEach((btn) => btn.onclick = () => addCardForm(btn));
$$("[data-menu]", view).forEach((btn) => btn.onclick = () => listDialog(btn.dataset.menu));
const addList = $("#add-list"); if (addList) addList.onclick = newListDialog;
if (canEdit()) wireDrag(view);
}
function addCardForm(btn) {
const listId = btn.dataset.add;
const form = document.createElement("form");
form.className = "add-form";
form.innerHTML = `<textarea class="textarea" style="min-height:60px" placeholder="Card title" required></textarea>
<div class="row"><button class="btn primary">Add card</button><button type="button" class="btn ghost">Cancel</button></div>`;
btn.replaceWith(form);
const ta = $("textarea", form); ta.focus();
ta.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } if (e.key === "Escape") renderBoard(); };
$(".btn.ghost", form).onclick = () => renderBoard();
form.onsubmit = async (e) => {
e.preventDefault();
const title = ta.value.trim(); if (!title) return;
const ok = await attempt(() => api("POST", `/api/boards/${state.board.id}/cards`, { title, list: listId }));
if (ok) { await refresh(); const again = $(`[data-add="${listId}"]`); if (again) addCardForm(again); }
};
}
// drag and drop between and within lists
function wireDrag(root) {
const placeholder = document.createElement("div"); placeholder.className = "placeholder";
$$(".card", root).forEach((el) => {
el.addEventListener("dragstart", (e) => { state.drag = el.dataset.id; el.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", el.dataset.id); });
el.addEventListener("dragend", () => { el.classList.remove("dragging"); placeholder.remove(); $$(".cards.over").forEach((c) => c.classList.remove("over")); state.drag = null; });
});
$$(".cards", root).forEach((zone) => {
zone.addEventListener("dragover", (e) => {
if (!state.drag) return;
e.preventDefault();
zone.classList.add("over");
const after = [...zone.querySelectorAll(".card:not(.dragging)")].find((c) => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2);
if (after) zone.insertBefore(placeholder, after); else zone.append(placeholder);
});
zone.addEventListener("dragleave", (e) => { if (!zone.contains(e.relatedTarget)) zone.classList.remove("over"); });
zone.addEventListener("drop", async (e) => {
e.preventDefault();
const id = state.drag; if (!id) return;
const index = [...zone.children].filter((n) => n === placeholder || (n.classList.contains("card") && !n.classList.contains("dragging"))).indexOf(placeholder);
const dragged = $(`.card[data-id="${CSS.escape(id)}"]`);
placeholder.replaceWith(dragged); dragged.classList.remove("dragging");
zone.classList.remove("over");
const card = state.board.lists.flatMap((l) => l.cards).find((c) => c.id === id);
const filtered = state.filter !== "all" || state.query.trim();
// With a filter on, hidden cards make a visual index ambiguous, so drop at the end.
await attempt(() => api("POST", `/api/cards/${id}/move`, { list: zone.dataset.list, index: filtered ? null : index, version: card && card.version }));
await refresh();
});
});
}
// ---------------------------------------------------------------- card drawer
function openDrawerShell() {
$("#drawer").classList.add("open"); $("#drawer").setAttribute("aria-hidden", "false"); $("#scrim").hidden = false;
}
function closeDrawer(silent) {
$("#drawer").classList.remove("open"); $("#drawer").setAttribute("aria-hidden", "true"); $("#scrim").hidden = true;
state.card = null;
if (!silent && state.view === "board") refresh();
}
async function openCard(id, keepScroll) {
const card = await attempt(() => api("GET", "/api/cards/" + encodeURIComponent(id)));
if (!card) return;
if (!state.board || state.board.id !== card.board_id) {
state.board = await api("GET", "/api/boards/" + card.board_id);
}
state.card = card;
const scroll = keepScroll ? $("#drawer-body").scrollTop : 0;
renderCard();
$("#drawer-body").scrollTop = scroll;
openDrawerShell();
}
function checklistItems(text) {
return (text || "").split("\n").map((line, i) => { const m = line.match(/^\s*[-*] \[( |x|X)\] (.*)$/); return m ? { i, done: m[1] !== " ", text: m[2] } : null; }).filter(Boolean);
}
function changeText(ch) {
const show = (v) => Array.isArray(v) ? (v.length ? v.join(", ") : "none") : (v === null || v === "" ? "none" : String(v));
const names = { list: "list", title: "title", description: "description", assignee: "assignee", due_date: "due date", labels: "labels", blocked_by: "blocked by" };
if (ch.field === "description") return "description edited";
return `${names[ch.field] || ch.field}: <s>${esc(show(ch.from))}</s> → ${esc(show(ch.to))}`;
}
function renderCard() {
const c = state.card, b = state.board, edit = c.role !== "viewer";
const items = checklistItems(c.description);
const plain = (c.description || "").split("\n").filter((l) => !/^\s*[-*] \[( |x|X)\] /.test(l)).join("\n").trim();
const listOpts = b.lists.map((l) => `<option value="${esc(l.id)}" ${l.id === c.list_id ? "selected" : ""}>${esc(l.name)}</option>`).join("");
const memberOpts = `<option value="">Nobody</option>` + b.members.map((m) => `<option value="${esc(m.email)}" ${m.email === c.assignee ? "selected" : ""}>${esc(m.email)}</option>`).join("");
const others = b.lists.flatMap((l) => l.cards).filter((o) => o.id !== c.id && !c.blocked_by.some((x) => x.id === o.id));
const dis = edit ? "" : "disabled";
$("#drawer-body").innerHTML = `
<div class="d-head"><span class="ref">${esc(c.ref)}</span><span>·</span><span>${esc(b.title)}</span>
${c.overdue ? `<span class="pill danger">Overdue</span>` : ""}${c.blocked ? `<span class="pill">Blocked</span>` : ""}
<button class="icon-btn" id="close" aria-label="Close">×</button></div>
<input class="d-title" id="f-title" value="${esc(c.title)}" ${dis} aria-label="Title">
<div class="fields">
<label for="f-list">List</label><select class="select" id="f-list" ${dis}>${listOpts}</select>
<label for="f-assignee">Assignee</label><select class="select" id="f-assignee" ${dis}>${memberOpts}</select>
<label for="f-due">Due date</label><input class="input" type="date" id="f-due" value="${esc(c.due_date || "")}" ${dis}>
<label>Labels</label><div class="label-edit" id="f-labels">${c.labels.map((l) => `<span class="label ${labelClass(l)}">${esc(l)}${edit ? `<button data-rm-label="${esc(l)}" aria-label="Remove ${esc(l)}">×</button>` : ""}</span>`).join("")}
${edit ? `<input id="f-label" placeholder="Add label" maxlength="24">` : c.labels.length ? "" : `<span class="prose">None</span>`}</div>
</div>
<div class="section"><h4>Description ${edit ? `<button class="btn ghost" id="edit-desc">Edit</button>` : ""}</h4>
<div id="desc">${plain ? `<div class="prose">${esc(plain)}</div>` : `<div class="prose" style="color:var(--muted)">${edit ? "Add more detail…" : "No description."}</div>`}</div></div>
<div class="section"><h4>Checklist ${items.length ? `<span>${items.filter((x) => x.done).length}/${items.length}</span>` : ""}
${edit ? `<button class="btn" id="suggest">Suggest steps</button>` : ""}</h4>
${items.length ? `<div class="progress"><span style="width:${Math.round(100 * items.filter((x) => x.done).length / items.length)}%"></span></div>` : ""}
<div class="checklist">${items.map((it) => `<label><input type="checkbox" data-line="${it.i}" ${it.done ? "checked" : ""} ${dis}><span>${esc(it.text)}</span></label>`).join("") || `<div class="prose" style="color:var(--muted)">No checklist yet.</div>`}</div>
<div id="suggestions"></div></div>
<div class="section"><h4>Blocked by</h4>
<div class="mini">${c.blocked_by.map((x) => `<div class="item ${x.open ? "" : "done-blocker"}"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span>${edit ? `<button class="icon-btn" data-unblock="${esc(x.id)}" aria-label="Remove blocker">×</button>` : ""}</div>`).join("") || `<div class="prose" style="color:var(--muted)">Nothing. This card can move freely.</div>`}</div>
${edit && others.length ? `<div class="row" style="display:flex;gap:6px;margin-top:8px"><select class="select" id="f-blocker"><option value="">Add a blocking card…</option>${others.map((o) => `<option value="${esc(o.id)}">${esc(o.ref)} · ${esc(o.title)}</option>`).join("")}</select></div>` : ""}
${c.blocked ? `<button class="btn ghost" id="why-blocked" style="margin-top:6px">Show the full chain</button><div id="chain"></div>` : ""}
</div>
<div class="section"><h4>Similar cards <button class="btn ghost" id="similar">Find</button></h4><div id="similar-out"></div></div>
<div class="section"><h4>Comments</h4>
${edit ? `<form id="comment-form"><textarea class="textarea" style="min-height:64px" id="f-comment" placeholder="Write a comment"></textarea><div style="margin-top:6px"><button class="btn primary">Comment</button></div></form>` : ""}
${c.comment_list.slice().reverse().map((m) => `<div class="comment"><div class="who"><b>${esc(m.author)}</b> · ${esc(ago(m.created_at))}</div><div class="prose">${esc(m.body)}</div></div>`).join("")}
</div>
<div class="section"><h4>History</h4><ul class="timeline">${c.history.map((e) => `<li><div><b>${esc(e.actor)}</b> ${esc(e.action)}${e.note && e.action !== "commented" ? ` <span class="chg">${esc(e.note)}</span>` : ""}</div>
${e.changes.filter((x) => e.action !== "added the card").map((x) => `<div class="chg">${changeText(x)}</div>`).join("")}<div class="when">${esc(ago(e.happened_at))}</div></li>`).join("")}</ul></div>
${edit ? `<div class="d-foot"><button class="btn danger" id="archive">Archive card</button></div>` : ""}`;
$("#close").onclick = () => closeDrawer();
if (!edit) { wireSimilar(); return; }
const save = (fields) => attempt(async () => { state.card = { ...state.card, ...(await api("PATCH", "/api/cards/" + c.id, { ...fields, version: state.card.version })) }; await openCard(c.id, true); });
$("#f-title").onchange = (e) => e.target.value.trim() && save({ title: e.target.value });
$("#f-title").onkeydown = (e) => { if (e.key === "Enter") e.target.blur(); };
$("#f-assignee").onchange = (e) => save({ assignee: e.target.value || null });
$("#f-due").onchange = (e) => save({ due_date: e.target.value || null });
$("#f-list").onchange = (e) => attempt(async () => { await api("POST", `/api/cards/${c.id}/move`, { list: e.target.value, version: c.version }); await openCard(c.id, true); });
$$("[data-rm-label]").forEach((btn) => btn.onclick = () => save({ labels: c.labels.filter((l) => l !== btn.dataset.rmLabel) }));
$("#f-label").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) save({ labels: [...c.labels, e.target.value.trim()] }); };
$$(".checklist input").forEach((box) => box.onchange = () => {
const lines = c.description.split("\n"); const i = +box.dataset.line;
lines[i] = lines[i].replace(/\[( |x|X)\]/, box.checked ? "[x]" : "[ ]");
save({ description: lines.join("\n") });
});
$("#edit-desc").onclick = () => {
$("#desc").innerHTML = `<textarea class="textarea" id="f-desc">${esc(c.description)}</textarea><div style="display:flex;gap:6px;margin-top:6px"><button class="btn primary" id="save-desc">Save</button><button class="btn ghost" id="cancel-desc">Cancel</button></div>`;
$("#f-desc").focus();
$("#save-desc").onclick = () => save({ description: $("#f-desc").value });
$("#cancel-desc").onclick = renderCard;
};
$("#suggest").onclick = suggestSteps;
const blocker = $("#f-blocker");
if (blocker) blocker.onchange = (e) => e.target.value && attempt(async () => { await api("POST", `/api/cards/${c.id}/blockers`, { blocker: e.target.value }); await openCard(c.id, true); });
$$("[data-unblock]").forEach((btn) => btn.onclick = () => attempt(async () => { await api("DELETE", `/api/cards/${c.id}/blockers/${encodeURIComponent(btn.dataset.unblock)}`); await openCard(c.id, true); }));
const why = $("#why-blocked");
if (why) why.onclick = async () => {
const r = await attempt(() => api("GET", `/api/cards/${c.id}/blocking`));
if (r) $("#chain").innerHTML = `<div class="mini" style="margin-top:6px">${r.open_blockers.map((x) => `<div class="item" style="margin-left:${(x.depth - 1) * 14}px"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span><span class="chg" style="margin-left:auto">${esc(x.list || "")}${x.assignee ? " · " + esc(x.assignee.split("@")[0]) : ""}</span></div>`).join("")}</div>`;
};
$("#comment-form").onsubmit = (e) => { e.preventDefault(); const body = $("#f-comment").value.trim(); if (body) attempt(async () => { await api("POST", `/api/cards/${c.id}/comments`, { body }); await openCard(c.id, true); }); };
$("#archive").onclick = () => { if (confirm(`Archive ${c.ref}? Its history is kept.`)) attempt(async () => { await api("DELETE", "/api/cards/" + c.id); closeDrawer(); }, `${c.ref} archived`); };
wireSimilar();
}
function wireSimilar() {
const c = state.card;
$("#similar").onclick = async () => {
$("#similar-out").innerHTML = `<span class="spin"></span>`;
const r = await attempt(() => api("GET", `/api/cards/${c.id}/similar`));
if (!r) { $("#similar-out").innerHTML = ""; return; }
$("#similar-out").innerHTML = `<div class="mini">${r.similar.map((x) => `<div class="item" data-open="${esc(x.id)}" style="cursor:pointer"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span><span class="chg" style="margin-left:auto">${esc(x.list)}</span></div>`).join("") || `<div class="prose" style="color:var(--muted)">No close matches on this board.</div>`}</div>
<div class="source">${r.method === "embeddings" ? "Matched by meaning with Dodil Models embeddings." : "Matched by shared words."}</div>`;
$$("[data-open]", $("#similar-out")).forEach((el) => el.onclick = () => openCard(el.dataset.open));
};
}
async function suggestSteps() {
const c = state.card, out = $("#suggestions");
out.innerHTML = `<div class="suggest"><span class="spin"></span> Drafting steps with Dodil Models…</div>`;
const r = await attempt(() => api("POST", `/api/cards/${c.id}/checklist/suggest`));
if (!r) { out.innerHTML = ""; return; }
if (!r.steps.length) { out.innerHTML = `<div class="suggest">No steps came back. Try again in a moment.</div>`; return; }
out.innerHTML = `<div class="suggest">${r.steps.map((s, i) => `<label><input type="checkbox" checked data-step="${i}"><span>${esc(s)}</span></label>`).join("")}
<div style="display:flex;gap:6px;margin-top:8px"><button class="btn primary" id="apply">Add selected</button><button class="btn ghost" id="discard">Discard</button></div></div>`;
$("#discard").onclick = () => out.innerHTML = "";
$("#apply").onclick = () => {
const steps = $$("[data-step]", out).filter((b) => b.checked).map((b) => r.steps[+b.dataset.step]);
if (steps.length) attempt(async () => { await api("POST", `/api/cards/${c.id}/checklist`, { steps }); await openCard(c.id, true); }, "Checklist added");
};
}
async function activityDrawer() {
const rows = await attempt(() => api("GET", `/api/boards/${state.board.id}/activity?limit=80`));
if (!rows) return;
state.card = null;
$("#drawer-body").innerHTML = `<div class="d-head"><span>${esc(state.board.title)}</span><button class="icon-btn" id="close" aria-label="Close">×</button></div>
<h2 style="margin:0 0 16px;font-size:20px">History</h2>
<ul class="timeline">${rows.map((e) => `<li><div><b>${esc(e.actor)}</b> ${esc(e.action)} ${e.card_ref ? `<a href="#" data-card="${esc(e.card_id)}" style="font-family:var(--mono);font-size:12px">${esc(e.card_ref)}</a>` : ""} ${e.note ? `<span class="chg">${esc(e.note)}</span>` : ""}</div>
${e.changes.filter(() => e.action !== "added the card").map((x) => `<div class="chg">${changeText(x)}</div>`).join("")}<div class="when">${esc(ago(e.happened_at))}</div></li>`).join("") || `<li>No history yet.</li>`}</ul>`;
$("#close").onclick = () => closeDrawer();
$$("[data-card]").forEach((a) => a.onclick = (e) => { e.preventDefault(); openCard(a.dataset.card); });
openDrawerShell();
}
// ---------------------------------------------------------------- dialogs
function dialog(html, wire) {
const d = $("#dialog"); $("#dialog-body").innerHTML = html; wire && wire($("#dialog-body")); d.showModal();
return d;
}
function newBoardDialog() {
dialog(`<h3>New board</h3><input class="input" id="nb-title" placeholder="e.g. Ops" required maxlength="200">
<p class="source">It starts with To do, Doing and Done. You can rename, reorder and add lists.</p>
<div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary" value="ok">Create board</button></div>`, (f) => {
f.onsubmit = async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const b = await attempt(() => api("POST", "/api/boards", { title: $("#nb-title").value }));
if (b) { $("#dialog").close(); await loadBoards(); go("#board/" + b.id); }
};
});
}
function newListDialog() {
dialog(`<h3>Add a list</h3><input class="input" id="nl-name" placeholder="e.g. Review" required maxlength="200">
<div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary">Add list</button></div>`, (f) => {
f.onsubmit = async (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
if (await attempt(() => api("POST", `/api/boards/${state.board.id}/lists`, { name: $("#nl-name").value }))) { $("#dialog").close(); refresh(); }
};
});
}
function listDialog(listId) {
const lists = state.board.lists, idx = lists.findIndex((l) => l.id === listId), l = lists[idx];
dialog(`<h3>List settings</h3>
<label class="source" for="ls-name">Name</label><input class="input" id="ls-name" value="${esc(l.name)}" maxlength="200">
<label style="display:flex;gap:8px;align-items:center;margin-top:12px"><input type="checkbox" id="ls-done" ${l.done ? "checked" : ""}> Cards in this list count as done</label>
<div class="row"><button type="button" class="btn" id="ls-left" ${idx === 0 ? "disabled" : ""}>Move left</button><button type="button" class="btn" id="ls-right" ${idx === lists.length - 1 ? "disabled" : ""}>Move right</button><span style="flex:1"></span><button type="button" class="btn danger" id="ls-del">Delete</button></div>
<div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary">Save</button></div>`, (f) => {
const patch = (body) => attempt(async () => { await api("PATCH", `/api/boards/${state.board.id}/lists/${listId}`, body); $("#dialog").close(); await refresh(); });
$("#ls-left").onclick = () => patch({ index: idx - 1 });
$("#ls-right").onclick = () => patch({ index: idx + 1 });
$("#ls-del").onclick = () => attempt(async () => { await api("DELETE", `/api/boards/${state.board.id}/lists/${listId}`); $("#dialog").close(); await refresh(); });
f.onsubmit = (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault(); patch({ name: $("#ls-name").value, done: $("#ls-done").checked });
};
});
}
function membersDialog() {
const b = state.board, owner = b.role === "owner";
dialog(`<h3>Members of ${esc(b.title)}</h3>
${b.members.map((m) => `<div class="member">${avatar(m.email)}<span class="email">${esc(m.email)}${m.email === state.me ? " (you)" : ""}</span>
${owner && m.role !== "owner" ? `<select class="select" data-role="${esc(m.email)}"><option value="member" ${m.role === "member" ? "selected" : ""}>Member</option><option value="viewer" ${m.role === "viewer" ? "selected" : ""}>Viewer</option></select><button type="button" class="icon-btn" data-remove="${esc(m.email)}" aria-label="Remove">×</button>` : `<span class="pill">${esc(m.role[0].toUpperCase() + m.role.slice(1))}</span>`}</div>`).join("")}
${owner ? `<div class="row"><input class="input" id="inv-email" type="email" placeholder="name@company.com"><select class="select" id="inv-role" style="width:auto"><option value="member">Member</option><option value="viewer">Viewer</option></select><button class="btn primary">Invite</button></div>
<p class="source">Members can edit cards and lists. Viewers can only read. They sign in with this email address.</p>` : `<p class="source">Only the owner can invite people or change roles.</p>`}
<div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Close</button></div>`, (f) => {
const after = async () => { await refresh(); membersDialog(); };
$$("[data-role]", f).forEach((s) => s.onchange = () => attempt(async () => { await api("POST", `/api/boards/${b.id}/members`, { email: s.dataset.role, role: s.value }); await after(); }, "Role updated"));
$$("[data-remove]", f).forEach((btn) => btn.onclick = () => attempt(async () => { await api("DELETE", `/api/boards/${b.id}/members/${encodeURIComponent(btn.dataset.remove)}`); await after(); }, "Removed"));
f.onsubmit = (e) => {
if (e.submitter && e.submitter.value === "cancel") return;
e.preventDefault();
const email = $("#inv-email").value.trim(); if (!email) return;
attempt(async () => { await api("POST", `/api/boards/${b.id}/members`, { email, role: $("#inv-role").value }); await after(); }, "Invited " + email);
};
});
}
async function summaryDialog() {
const d = dialog(`<h3>Daily summary · ${esc(state.board.title)}</h3><div id="sum"><span class="spin"></span> Reading the board…</div>
<div class="actions"><button class="btn ghost" value="cancel">Close</button></div>`);
const r = await attempt(() => api("GET", `/api/boards/${state.board.id}/summary`));
if (!r || !d.open) return;
$("#sum").innerHTML = `<div class="summary-text">${esc(r.summary)}</div><div class="source">${r.source === "dodil-models" ? "Written by Dodil Models from today's board facts." : "Board facts (the summary model did not answer in time)."}</div>`;
}
// ---------------------------------------------------------------- cross-board views
function rowsHtml(cards) {
if (!cards.length) return `<div class="rows"><div class="empty">Nothing here.</div></div>`;
return `<div class="rows">${cards.map((c) => `<div class="rowi" data-card="${esc(c.id)}"><span class="ref">${esc(c.ref)}</span>
<span>${esc(c.title)} ${c.blocked ? `<span class="pill">Blocked</span>` : ""}</span><span class="where">${esc(c.board)} · ${esc(c.list)}</span>
<span class="due ${dueClass(c)}">${c.due_date ? esc(dueText(c.due_date)) : ""}</span>${avatar(c.assignee)}</div>`).join("")}</div>`;
}
function wireRows() { $$(".rowi").forEach((r) => r.onclick = () => openCard(r.dataset.card)); }
async function showPlate() {
state.view = "plate"; state.board = null;
$("#top").innerHTML = `<h1>My work</h1><span class="grow"></span>`;
const p = await attempt(() => api("GET", "/api/me/plate?days=7"));
if (!p) return;
const none = !p.overdue.length && !p.due_soon.length && !p.no_date_or_later.length;
$("#view").innerHTML = none && !state.boards.length
? `<div class="empty-board"><div><h2>Welcome to Quadro</h2><p>Create a board to start planning with your team.</p><button class="btn primary" id="first-board">Create a board</button></div></div>`
: `<div class="page"><h3>Overdue</h3>${rowsHtml(p.overdue)}<h3>Due in the next 7 days</h3>${rowsHtml(p.due_soon)}<h3>Later or no date</h3>${rowsHtml(p.no_date_or_later)}</div>`;
const first = $("#first-board"); if (first) first.onclick = newBoardDialog;
wireRows();
}
async function showOverdue() {
state.view = "overdue"; state.board = null;
$("#top").innerHTML = `<h1>Overdue</h1><span class="grow"></span>`;
const list = await attempt(() => api("GET", "/api/overdue"));
if (!list) return;
$("#view").innerHTML = `<div class="page"><h3>Open cards past their due date, on every board you can see</h3>${rowsHtml(list)}</div>`;
wireRows();
}
async function showSearch(q) {
state.view = "search"; state.board = null;
$("#top").innerHTML = `<h1>Search</h1><input class="search" id="gsearch" value="${esc(q)}" aria-label="Search all boards"><span class="grow"></span>`;
$("#gsearch").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) go("#search/" + encodeURIComponent(e.target.value.trim())); };
const list = await attempt(() => api("GET", "/api/search?q=" + encodeURIComponent(q)));
if (!list) return;
$("#view").innerHTML = `<div class="page"><h3>${list.length} result${list.length === 1 ? "" : "s"} for “${esc(q)}”</h3>${rowsHtml(list)}</div>`;
wireRows();
}
// ---------------------------------------------------------------- start
async function start() {
const me = await api("GET", "/api/me");
state.me = me.email;
$("#me").textContent = me.email;
$("#new-board").onclick = newBoardDialog;
$$(".views .nav").forEach((b) => b.onclick = () => go("#" + b.dataset.view));
$("#scrim").onclick = () => closeDrawer();
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("#drawer").classList.contains("open")) closeDrawer(); });
window.addEventListener("hashchange", route);
await loadBoards();
await route();
}
start().catch((e) => { if (e.message !== "sign in") toast(e.message, true); });