eduard/cobra-boards
Cobra Boards, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Dodil Models + MCP.
A remix template · 1 remixes · Remix eduard/cobra-boards
app/boards/doors/static/app.js
// Cobra Boards: the web door. Plain JS, no build step. Every action calls the JSON API, which
// runs the same use cases as the MCP tools; the page never decides a rule on its own.
"use strict";
const $ = (s, el = document) => el.querySelector(s);
const main = $("#main"), layer = $("#layer");
const state = { me: null, boards: [], board: null, dragging: null, stamp: null, templates: null };
// ---------------------------------------------------------------- helpers
function esc(v) {
return String(v ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
async function api(path, opts = {}) {
const res = await fetch(path, { headers: { "content-type": "application/json" }, credentials: "same-origin",
...opts, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) });
if (res.status === 401) { location.href = "/.dodil/auth/login?return_to=" + encodeURIComponent(location.pathname + location.hash); throw new Error("sign in"); }
const data = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) { const e = new Error((data && data.detail) || res.statusText); e.status = res.status; throw e; }
return data;
}
function toast(msg, err = false) {
const t = document.createElement("div");
t.className = "toast" + (err ? " err" : ""); t.textContent = msg; t.setAttribute("role", err ? "alert" : "status");
document.body.appendChild(t); setTimeout(() => t.remove(), err ? 5200 : 2600);
}
async function act(fn, okMsg) {
try { const r = await fn(); if (okMsg) toast(okMsg); return r; }
catch (e) { if (e.message !== "sign in") toast(e.status === 409 ? "Someone changed this card a moment ago. Reloaded the latest." : e.message, true); throw e; }
}
const COLORS = ["#f0cf6a", "#6fd6a8", "#8fb8ff", "#ff9e7a", "#d7a6ff", "#7ee0e8", "#ffc46b", "#b8e986"];
function avatar(email, title = true) {
if (!email) return "";
let h = 0; for (const c of email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
const ini = email.split("@")[0].split(/[._-]/).filter(Boolean).slice(0, 2).map(p => p[0]).join("").toUpperCase();
return `<span class="av" style="background:${COLORS[h % COLORS.length]}" ${title ? `title="${esc(email)}"` : ""}>${esc(ini || "?")}</span>`;
}
function todayISO() { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset() * 6e4).toISOString().slice(0, 10); }
function dueChip(c) {
if (!c.due_date) return "";
if (c.done) return `<span class="chip done">✓ ${esc(fmtDate(c.due_date))}</span>`;
if (c.overdue) return `<span class="chip late">Overdue · ${esc(fmtDate(c.due_date))}</span>`;
const days = (new Date(c.due_date) - new Date(todayISO())) / 864e5;
return `<span class="chip ${days <= 2 ? "soon" : "due"}">${days === 0 ? "Due today" : "Due " + esc(fmtDate(c.due_date))}</span>`;
}
function fmtDate(iso) { const d = new Date(iso + "T00:00:00"); return d.toLocaleDateString(undefined, { day: "numeric", month: "short" }); }
function fmtTime(iso) { const d = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z"); return d.toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); }
function close() { layer.innerHTML = ""; if (location.hash.startsWith("#/c/")) history.replaceState(null, "", state.board ? "#/b/" + state.board.id : "#/"); }
// ---------------------------------------------------------------- sidebar
async function loadBoards() {
state.boards = await api("/api/boards");
const cur = state.board && state.board.id;
$("#boards").innerHTML = state.boards.map(b => `<button class="nav${b.id === cur ? " on" : ""}" data-go="#/b/${esc(b.id)}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="5" height="16" rx="1.5"/><rect x="10" y="4" width="5" height="11" rx="1.5"/><rect x="17" y="4" width="4" height="7" rx="1.5"/></svg>
<span>${esc(b.title)}</span><span class="count${b.overdue ? " hot" : ""}">${b.overdue ? b.overdue + " late" : b.open_cards}</span></button>`).join("")
|| `<div class="empty" style="padding:10px">No boards yet</div>`;
const late = state.boards.reduce((n, b) => n + b.overdue, 0);
$("#c-late").textContent = late || ""; $("#c-late").className = "count" + (late ? " hot" : "");
}
document.addEventListener("click", e => {
const go = e.target.closest("[data-go]");
if (go) { location.hash = go.dataset.go; $("#side").classList.remove("open"); }
const t = e.target.closest(".themes button");
if (t) { document.documentElement.dataset.theme = t.dataset.t; try { localStorage.setItem("cobra-theme", t.dataset.t); } catch (_) {} markTheme(); }
});
function markTheme() { const cur = document.documentElement.dataset.theme; document.querySelectorAll(".themes button").forEach(b => b.classList.toggle("on", b.dataset.t === cur)); }
$("#burger").onclick = () => $("#side").classList.toggle("open");
$("#newboard").onsubmit = e => {
e.preventDefault(); const t = $("#nbt").value.trim(); if (!t) return;
$("#nbt").value = ""; newBoard(t);
};
// a new board starts from a template: the lists it begins with
async function newBoard(title) {
state.templates = state.templates || await api("/api/templates");
dialog(`<div class="dlgh"><h2>New board: ${esc(title)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><form id="nbf"><div class="tpls">
${state.templates.map((t, i) => `<label class="tpl"><input type="radio" name="tpl" value="${esc(t.name)}" ${i === 0 ? "checked" : ""}>
<b>${esc(t.name[0].toUpperCase() + t.name.slice(1))}</b><span>${t.lists.map(esc).join(" → ")}</span></label>`).join("")}</div>
<div class="sec"><button class="btn primary">Create board</button></div></form></div>`, true);
$("#nbf").onsubmit = async e => { e.preventDefault();
const template = e.target.querySelector("input[name=tpl]:checked").value;
const b = await act(() => api("/api/boards", { method: "POST", body: { title, template } }), "Board created");
close(); location.hash = "#/b/" + b.id; };
}
// ---------------------------------------------------------------- notifications
async function loadNotes() {
try { const n = await api("/api/me/notifications"); const nb = $("#nb");
nb.hidden = !n.unread; nb.textContent = n.unread > 9 ? "9+" : n.unread; return n; } catch (_) { return null; }
}
$("#bell").onclick = async () => {
const n = await loadNotes(); if (!n) return;
const icon = { mention: "@", assigned: "→", comment: "💬" };
dialog(`<div class="dlgh"><h2>Notifications</h2>${n.unread ? `<button class="btn" id="allread">Mark all read</button>` : ""}<button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><div class="notes">${n.items.map(x => `<button class="note ${x.read ? "" : "unread"}" data-note="${esc(x.id)}" data-card="${esc(x.card_id || "")}" data-board="${esc(x.board_id)}">
<span class="ni">${icon[x.kind] || "•"}</span><span class="nt">${avatar(x.actor)} <b>${esc(x.actor.split("@")[0])}</b> ${esc(x.summary)}
<span class="w">${esc(x.board)} · ${esc(fmtTime(x.at))}</span></span></button>`).join("")
|| `<div class="empty"><span class="big">🐍</span>You're all caught up.</div>`}</div></div>`, true);
const ar = $("#allread"); if (ar) ar.onclick = async () => { await act(() => api("/api/me/notifications/read", { method: "POST", body: {} })); close(); loadNotes(); };
layer.querySelectorAll("[data-note]").forEach(b => b.onclick = async () => {
await api("/api/me/notifications/read", { method: "POST", body: { ids: [b.dataset.note] } }).catch(() => {});
loadNotes(); close();
if (b.dataset.card) { state.board = await api("/api/boards/" + b.dataset.board); location.hash = "#/c/" + b.dataset.card; }
else location.hash = "#/b/" + b.dataset.board;
});
};
// ---------------------------------------------------------------- live: the board updates itself
// The open board asks every few seconds for its newest history line. When someone else changed
// it, the board reloads in place and says who did what. No sockets, so it works on every replica.
setInterval(async () => {
if (document.hidden || !state.board || !location.hash.startsWith("#/b/") && !location.hash.startsWith("#/c/")) return;
try {
const s = await api(`/api/boards/${encodeURIComponent(state.board.id)}/stamp`);
if (state.stamp === null) { state.stamp = s.stamp; return; }
if (s.stamp === state.stamp) return;
state.stamp = s.stamp;
if (s.by && state.me && s.by !== state.me.email) {
toast(`${s.by.split("@")[0]} ${s.summary}`); pulseLive();
const y = main.scrollTop, x = ($("#cols") || {}).scrollLeft || 0;
await showBoard(state.board.id, true); main.scrollTop = y; if ($("#cols")) $("#cols").scrollLeft = x;
loadNotes();
}
} catch (_) { /* offline for a moment: try again next tick */ }
}, 4000);
setInterval(() => { if (!document.hidden) loadNotes(); }, 30000);
function pulseLive() { const d = $(".live"); if (d) { d.classList.remove("ping"); void d.offsetWidth; d.classList.add("ping"); } }
let qTimer;
$("#q").oninput = () => { clearTimeout(qTimer); qTimer = setTimeout(() => { const v = $("#q").value.trim(); location.hash = v ? "#/search/" + encodeURIComponent(v) : (state.board ? "#/b/" + state.board.id : "#/"); }, 300); };
// ---------------------------------------------------------------- board view
async function showBoard(id, live = false) {
const b = await api("/api/boards/" + encodeURIComponent(id));
if (!state.board || state.board.id !== b.id) state.stamp = null; // a new board: learn its stamp
state.board = b; await loadBoards();
const all = b.lists.flatMap(l => l.cards);
const open = all.filter(c => !c.done);
const late = open.filter(c => c.overdue).length, blocked = open.filter(c => c.blocked_by.length).length;
const due = open.filter(c => c.due_date === todayISO()).length;
main.innerHTML = `
<div class="bhead">
<h1>${esc(b.title)}</h1><span class="role ${esc(b.role)}">${esc(b.role)}</span>
<span class="live${live ? " ping" : ""}" title="Live: changes by your teammates appear here within seconds">live</span>
<div class="avs">${b.members.map(m => avatar(m.email)).join("")}</div>
<div class="tools">
<button class="btn ai" id="brief">Daily briefing</button>
<button class="btn" id="activity">Activity</button>
<button class="btn" id="archived">Archived</button>
<button class="btn" id="members">${b.is_owner ? "Members" : "People"}</button>
</div>
</div>
${b.can_edit ? "" : `<div class="banner">You're a viewer on this board: you can see everything, but cards can't be moved or edited.</div>`}
<div class="stats">
<div class="stat"><b>${open.length}</b><span>open cards</span></div>
<div class="stat ${late ? "hot" : ""}"><b>${late}</b><span>overdue</span></div>
<div class="stat ${blocked ? "warn" : ""}"><b>${blocked}</b><span>blocked</span></div>
<div class="stat"><b>${due}</b><span>due today</span></div>
</div>
<div class="cols" id="cols">
${b.lists.map(l => column(b, l)).join("")}
${b.can_edit ? `<form class="addlist" id="addlist"><input placeholder="+ Add a list" maxlength="60" aria-label="New list name"></form>` : ""}
</div>`;
$("#brief").onclick = () => briefing(b);
$("#activity").onclick = () => activity(b);
$("#archived").onclick = () => archivedList(b);
$("#members").onclick = () => members(b);
const al = $("#addlist");
if (al) al.onsubmit = async e => { e.preventDefault(); const v = al.querySelector("input").value.trim(); if (!v) return;
await act(() => api(`/api/boards/${b.id}/lists`, { method: "POST", body: { name: v } }), "List added"); showBoard(b.id); };
wireColumns(b);
}
function column(b, l) {
return `<section class="col" data-list="${esc(l.id)}" aria-label="${esc(l.name)}">
<div class="colh"><span>${esc(l.name)}</span><span class="n">${l.cards.length}</span>${l.is_done ? `<span class="done-tag">✓ done</span>` : ""}
${b.can_edit ? `<button class="btn icon ghost colmenu" data-listmenu="${esc(l.id)}" aria-label="List options">⋯</button>` : ""}</div>
<div class="cards" data-drop="${esc(l.id)}">${l.cards.map(c => cardTile(b, c)).join("")}</div>
${b.can_edit ? `<form class="addcard" data-add="${esc(l.id)}"><input placeholder="+ Add a card" maxlength="200" aria-label="New card in ${esc(l.name)}"></form>` : ""}
</section>`;
}
function cardTile(b, c) {
const cls = ["card", c.overdue ? "is-late" : "", c.blocked_by.length ? "is-blocked" : ""].join(" ");
return `<article class="${cls}" tabindex="0" draggable="${b.can_edit}" data-card="${esc(c.id)}" data-version="${c.version}" aria-label="Card ${c.number}: ${esc(c.title)}">
<div><span class="num">#${c.number}</span><span class="t">${esc(c.title)}</span></div>
<div class="meta">
${c.labels.map(x => `<span class="chip label">${esc(x)}</span>`).join("")}
${dueChip(c)}
${c.blocked_by.length ? `<span class="chip block" title="Waiting on ${c.blocked_by.map(n => "#" + n).join(", ")}">⛓ ${c.blocked_by.map(n => "#" + n).join(" ")}</span>` : ""}
${c.description ? `<span class="chip" title="Has a description">≡</span>` : ""}
${c.comments ? `<span class="chip" title="${c.comments} comment(s)">💬 ${c.comments}</span>` : ""}
${c.files ? `<span class="chip" title="${c.files} file(s)">📎 ${c.files}</span>` : ""}
${avatar(c.assignee)}
</div></article>`;
}
function wireColumns(b) {
document.querySelectorAll(".card").forEach(el => {
el.onclick = () => openCard(el.dataset.card);
el.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openCard(el.dataset.card); } };
if (!b.can_edit) return;
el.ondragstart = e => { state.dragging = el.dataset.card; el.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", el.dataset.card); };
el.ondragend = () => { el.classList.remove("dragging"); document.querySelectorAll(".drop-line").forEach(x => x.remove()); document.querySelectorAll(".col").forEach(x => x.classList.remove("dropping")); };
});
document.querySelectorAll("[data-add]").forEach(f => f.onsubmit = async e => {
e.preventDefault(); const inp = f.querySelector("input"), t = inp.value.trim(); if (!t) return;
inp.disabled = true;
try { await act(() => api(`/api/boards/${b.id}/cards`, { method: "POST", body: { title: t, list: f.dataset.add } })); }
finally { inp.disabled = false; }
await showBoard(b.id); const again = document.querySelector(`[data-add="${CSS.escape(f.dataset.add)}"] input`); if (again) again.focus();
});
document.querySelectorAll("[data-listmenu]").forEach(btn => btn.onclick = () => listMenu(b, b.lists.find(l => l.id === btn.dataset.listmenu)));
if (!b.can_edit) return;
document.querySelectorAll(".cards").forEach(zone => {
zone.ondragover = e => {
if (!state.dragging) return; e.preventDefault();
zone.closest(".col").classList.add("dropping");
const before = cardAfter(zone, e.clientY);
document.querySelectorAll(".drop-line").forEach(x => x.remove());
const line = document.createElement("div"); line.className = "drop-line";
before ? zone.insertBefore(line, before) : zone.appendChild(line);
};
zone.ondragleave = e => { if (!zone.contains(e.relatedTarget)) zone.closest(".col").classList.remove("dropping"); };
zone.ondrop = async e => {
e.preventDefault(); const id = state.dragging; state.dragging = null;
const before = cardAfter(zone, e.clientY);
const card = document.querySelector(`[data-card="${CSS.escape(id)}"]`);
await act(() => api(`/api/cards/${encodeURIComponent(id)}/move`, { method: "POST",
body: { list: zone.dataset.drop, before_card: before ? before.dataset.card : null, expected_version: Number(card.dataset.version) } })).catch(() => {});
showBoard(b.id);
};
});
}
function cardAfter(zone, y) {
const els = [...zone.querySelectorAll(".card:not(.dragging)")];
return els.find(el => { const r = el.getBoundingClientRect(); return y < r.top + r.height / 2; }) || null;
}
function listMenu(b, l) {
dialog(`<div class="dlgh"><h2>List: ${esc(l.name)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><form id="lf">
<div class="sec"><label for="ln">Name</label><input class="field" id="ln" value="${esc(l.name)}" maxlength="60"></div>
<div class="sec"><label class="check"><input type="checkbox" id="ld" ${l.is_done ? "checked" : ""}><span>Cards in this list are finished (never overdue, never blocking)</span></label></div>
<div class="sec"><button class="btn primary">Save</button></div></form></div>`, true);
$("#lf").onsubmit = async e => { e.preventDefault();
await act(() => api(`/api/boards/${b.id}/lists/${l.id}`, { method: "PATCH", body: { name: $("#ln").value, is_done: $("#ld").checked } }), "List saved");
close(); showBoard(b.id); };
}
// ---------------------------------------------------------------- dialogs
function dialog(html, small = false) {
layer.innerHTML = `<div class="veil" data-veil><div class="dlg ${small ? "small" : ""}" role="dialog" aria-modal="true">${html}</div></div>`;
layer.querySelector("[data-veil]").onclick = e => { if (e.target.dataset.veil !== undefined || e.target.closest("[data-x]")) close(); };
const f = layer.querySelector("input, textarea, button"); if (f) f.focus();
}
document.addEventListener("keydown", e => { if (e.key === "Escape" && layer.innerHTML) close(); });
async function openCard(id) {
const c = await act(() => api("/api/cards/" + encodeURIComponent(id)));
if (location.hash !== "#/c/" + id) history.replaceState(null, "", "#/c/" + id);
const ro = !c.can_edit || c.archived; // an archived card is read-only until it is restored
const lists = state.board && state.board.id === c.board_id ? state.board.lists : [{ id: c.list_id, name: c.list }];
const done = c.checklist.filter(i => i.done).length;
const size = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB";
const body = t => esc(t).replace(/(^|[\s(])@([\w.+-]+(?:@[\w-]+(?:\.[\w-]+)+)?)/g, '$1<span class="at">@$2</span>');
dialog(`
<div class="dlgh"><span class="num">#${c.number}</span>
<input class="title" id="ct" value="${esc(c.title)}" ${ro ? "disabled" : ""} maxlength="200" aria-label="Title">
<button class="btn icon ghost" data-x aria-label="Close">✕</button></div>
${c.archived ? `<div class="banner arch">Archived${c.archived_by ? " by " + esc(c.archived_by.split("@")[0]) : ""}: off the board, nothing lost.
${c.can_edit ? `<button class="btn primary" id="restore">Restore to ${esc(c.list)}</button>` : ""}</div>` : ""}
<div class="dlgb">
<div>
<div class="sec"><label for="cd">Description</label>
<textarea class="field" id="cd" ${ro ? "disabled" : ""} maxlength="5000" placeholder="${ro ? "" : "Add the details…"}">${esc(c.description)}</textarea></div>
<div class="sec"><span class="lab">Checklist ${c.checklist.length ? `· ${done}/${c.checklist.length}` : ""}</span>
${c.checklist.length ? `<div class="progress"><i style="width:${Math.round(100 * done / c.checklist.length)}%"></i></div>` : ""}
<div id="cl">${c.checklist.map(i => `<label class="check ${i.done ? "done" : ""}"><input type="checkbox" data-item="${esc(i.id)}" ${i.done ? "checked" : ""} ${ro ? "disabled" : ""}><span>${esc(i.text)}</span>${ro ? "" : `<button class="x" data-del="${esc(i.id)}" aria-label="Remove step" type="button">✕</button>`}</label>`).join("")}</div>
${ro ? "" : `<form class="inline" id="addstep" style="margin-top:6px"><input class="field" placeholder="Add a step" maxlength="200"><button class="btn">Add</button></form>`}
<button class="btn ai" id="suggest" style="margin-top:8px">Suggest a checklist</button>
<div id="sugg"></div></div>
<div class="sec"><span class="lab">Comments ${c.comments.length ? "· " + c.comments.length : ""}</span>
<div class="comments">${c.comments.map(x => `<div class="cm">${avatar(x.author)}<div><div class="cmh"><b>${esc(x.author.split("@")[0])}</b><span class="w">${esc(fmtTime(x.at))}</span></div>
<div class="cmb">${body(x.body)}</div></div></div>`).join("") || `<div class="w" style="color:var(--faint)">No comments yet.</div>`}</div>
${c.can_edit && !c.archived ? `<form id="addcm" class="cmform"><textarea class="field" maxlength="2000" rows="2" placeholder="Write a comment… type @ and a name to mention a teammate"></textarea>
<div class="cmhint">${c.people.filter(p => p !== (state.me && state.me.email)).map(p => `<button type="button" class="chip" data-at="${esc(p.split("@")[0])}">@${esc(p.split("@")[0])}</button>`).join("")}
<button class="btn primary" style="margin-left:auto">Comment</button></div></form>` : ""}</div>
<div class="sec"><span class="lab">History</span>
<div class="tl">${c.history.map(h => `<div class="e"><div>${avatar(h.actor)} <b>${esc(h.actor.split("@")[0])}</b> ${esc(h.summary)}</div><div class="w">${esc(fmtTime(h.at))}</div></div>`).join("")}</div></div>
</div>
<aside>
<div class="sec"><label for="cls">List</label><select class="field" id="cls" ${ro ? "disabled" : ""}>
${lists.map(l => `<option value="${esc(l.id)}" ${l.id === c.list_id ? "selected" : ""}>${esc(l.name)}</option>`).join("")}</select></div>
<div class="sec"><label for="ca">Assignee</label><select class="field" id="ca" ${ro ? "disabled" : ""}>
<option value="">Nobody</option>${c.people.map(p => `<option ${p === c.assignee ? "selected" : ""}>${esc(p)}</option>`).join("")}</select></div>
<div class="sec"><label for="cdue">Due date</label><input class="field" type="date" id="cdue" value="${esc(c.due_date || "")}" ${ro ? "disabled" : ""}>
${c.overdue ? `<div style="margin-top:6px">${dueChip(c)}</div>` : ""}</div>
<div class="sec"><label for="clab">Labels</label><input class="field" id="clab" value="${esc(c.labels.join(", "))}" placeholder="design, urgent" ${ro ? "disabled" : ""}></div>
${ro ? "" : `<div class="sec"><button class="btn primary" id="save" style="width:100%">Save changes</button></div>`}
<div class="sec"><span class="lab">Blocked by</span>
${c.blocked_by_all.map(x => `<div class="blk ${x.done ? "fin" : ""}"><span class="num">#${x.number}</span>${esc(x.title)}${ro ? "" : `<button class="btn icon ghost" data-unblock="${x.number}" style="margin-left:auto;height:24px" aria-label="Remove">✕</button>`}</div>`).join("") || `<div class="w" style="color:var(--faint)">Nothing</div>`}
${ro ? "" : `<form class="inline" id="addblk" style="margin-top:6px"><input class="field" placeholder="Card number, e.g. 7" inputmode="numeric"><button class="btn">Add</button></form>`}
${c.blocks.length ? `<div style="margin-top:6px;color:var(--muted);font-size:13px">Blocks ${c.blocks.map(n => "#" + n).join(", ")}</div>` : ""}
<button class="btn" id="why" style="margin-top:8px;width:100%">What is blocking this?</button><div id="whyout"></div></div>
<div class="sec"><button class="btn ai" id="similar" style="width:100%">Find similar cards</button><div id="simout"></div></div>
<div class="sec"><span class="lab">Files ${c.attachments.length ? "· " + c.attachments.length : ""}</span>
${c.attachments.map(a => `<div class="att">${a.content_type.startsWith("image/") ? `<img src="${esc(a.url)}" alt="" loading="lazy">` : `<span class="ficon">📄</span>`}
<a href="${esc(a.url)}" target="_blank" rel="noopener" title="${esc(a.name)}">${esc(a.name)}</a><span class="w">${size(a.size)}</span>
${ro ? "" : `<button class="btn icon ghost" data-rmatt="${esc(a.id)}" aria-label="Remove ${esc(a.name)}">✕</button>`}</div>`).join("") || `<div class="w" style="color:var(--faint)">No files</div>`}
${ro ? "" : `<label class="btn upl" style="margin-top:6px;width:100%;justify-content:center">Attach a file<input type="file" id="upfile" hidden></label>
<div class="w" style="color:var(--faint);font-size:12px;margin-top:4px">Up to 10 MB. Stored in this board's DataK3 bucket.</div>`}</div>
${c.can_edit && !c.archived ? `<div class="sec"><button class="btn" id="archive" style="width:100%">Archive card</button></div>` : ""}
</aside>
</div>`);
const refresh = () => { openCard(id); if (state.board) showBoard(state.board.id); };
const rs = $("#restore");
if (rs) rs.onclick = async () => { await act(() => api(`/api/cards/${encodeURIComponent(id)}/restore`, { method: "POST" }), "Restored"); refresh(); };
const ar = $("#archive");
if (ar) ar.onclick = async () => {
await act(() => api(`/api/cards/${encodeURIComponent(id)}/archive`, { method: "POST" }), `Archived #${c.number}. Find it under “Archived”.`);
close(); if (state.board) showBoard(state.board.id); };
const cf = $("#addcm");
if (cf) {
const ta = cf.querySelector("textarea");
cf.querySelectorAll("[data-at]").forEach(b => b.onclick = () => { ta.value = (ta.value.trim() ? ta.value.trimEnd() + " " : "") + "@" + b.dataset.at + " "; ta.focus(); });
ta.onkeydown = e => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) cf.requestSubmit(); };
cf.onsubmit = async e => { e.preventDefault(); const v = ta.value.trim(); if (!v) return;
const r = await act(() => api(`/api/cards/${encodeURIComponent(id)}/comments`, { method: "POST", body: { body: v } }));
if (r && r.mentioned.length) toast("Notified " + r.mentioned.map(m => m.split("@")[0]).join(", "));
openCard(id); };
}
if (!ro) {
$("#upfile").onchange = async e => {
const f = e.target.files[0]; if (!f) return;
if (f.size > 10 * 1024 * 1024) { toast("A file can be at most 10 MB", true); return; }
const res = await fetch(`/api/cards/${encodeURIComponent(id)}/attachments?name=${encodeURIComponent(f.name)}`,
{ method: "POST", body: f, credentials: "same-origin", headers: { "content-type": f.type || "application/octet-stream" } });
if (!res.ok) { const d = await res.json().catch(() => ({})); toast(d.detail || "Upload failed", true); return; }
toast("Attached " + f.name); refresh(); };
layer.querySelectorAll("[data-rmatt]").forEach(x => x.onclick = async () => {
await act(() => api(`/api/attachments/${x.dataset.rmatt}`, { method: "DELETE" }), "File removed"); refresh(); });
$("#save").onclick = async () => {
const body = { expected_version: c.version, title: $("#ct").value, description: $("#cd").value,
assignee: $("#ca").value || null, due_date: $("#cdue").value || null,
labels: $("#clab").value.split(",").map(s => s.trim()).filter(Boolean) };
try { await act(() => api("/api/cards/" + id, { method: "PATCH", body }), "Saved");
if ($("#cls").value !== c.list_id) await act(() => api(`/api/cards/${id}/move`, { method: "POST", body: { list: $("#cls").value } }));
} catch (e) { /* toast already shown */ }
refresh();
};
$("#addstep").onsubmit = async e => { e.preventDefault(); const v = e.target.querySelector("input").value.trim(); if (!v) return;
await act(() => api(`/api/cards/${id}/checklist`, { method: "POST", body: { items: [v] } })); refresh(); };
$("#addblk").onsubmit = async e => { e.preventDefault(); const v = e.target.querySelector("input").value.trim().replace("#", ""); if (!v) return;
await act(() => api(`/api/cards/${id}/blockers`, { method: "POST", body: { blocked_by: `${c.board_id}-${v}` } }), `Now waits on #${v}`).catch(() => {}); refresh(); };
layer.querySelectorAll("[data-item]").forEach(cb => cb.onchange = async () => {
await act(() => api(`/api/cards/${id}/checklist/${cb.dataset.item}`, { method: "PATCH", body: { done: cb.checked } })); refresh(); });
layer.querySelectorAll("[data-del]").forEach(x => x.onclick = async e => { e.preventDefault();
await act(() => api(`/api/cards/${id}/checklist/${x.dataset.del}`, { method: "DELETE" })); refresh(); });
layer.querySelectorAll("[data-unblock]").forEach(x => x.onclick = async () => {
await act(() => api(`/api/cards/${id}/blockers/${c.board_id}-${x.dataset.unblock}`, { method: "DELETE" })); refresh(); });
}
$("#suggest").onclick = async () => {
$("#sugg").innerHTML = `<div class="suggest"><span class="spin"></span> Asking Dodil Models…</div>`;
try {
const s = await api(`/api/cards/${id}/checklist/suggest`, { method: "POST" });
$("#sugg").innerHTML = `<div class="suggest"><div class="note">Suggestions only: nothing is saved until you add it.</div>
${s.suggested_steps.map((t, i) => `<label class="check"><input type="checkbox" data-s="${i}" checked ${ro ? "disabled" : ""}><span>${esc(t)}</span></label>`).join("")}
${ro ? "" : `<button class="btn primary" id="addsugg" style="margin-top:6px">Add selected steps</button>`}</div>`;
if (!ro) $("#addsugg").onclick = async () => {
const items = s.suggested_steps.filter((_, i) => layer.querySelector(`[data-s="${i}"]`).checked);
if (items.length) await act(() => api(`/api/cards/${id}/checklist`, { method: "POST", body: { items } }), "Steps added");
refresh(); };
} catch (e) { $("#sugg").innerHTML = `<div class="suggest">${esc(e.message)}</div>`; }
};
$("#why").onclick = async () => {
const r = await act(() => api(`/api/cards/${id}/blocking`));
$("#whyout").innerHTML = !r.blocked ? `<div class="start">Nothing is blocking #${r.card}: it can go ahead.</div>` :
`<div class="chain" style="margin-top:8px">${r.chain.map(x => `<div class="lvl" style="padding-left:${(x.depth - 1) * 14}px"><span class="num">#${x.number}</span>${esc(x.title)} <span class="chip">${esc(x.list)}</span></div>`).join("")}</div>
<div class="start"><b>Start with:</b> ${r.start_with.map(x => `#${x.number} ${esc(x.title)}`).join(", ")}</div>`;
};
$("#similar").onclick = async () => {
$("#simout").innerHTML = `<div style="margin-top:8px"><span class="spin"></span> Searching by meaning…</div>`;
try {
const r = await api(`/api/cards/${id}/similar`, { method: "POST" });
$("#simout").innerHTML = r.cards.length ? r.cards.map(x => `<div class="blk" style="margin-top:6px"><span class="num">#${x.number}</span>${esc(x.title)}<span class="chip" style="margin-left:auto">${Math.round(x.similarity * 100)}%</span></div>`).join("")
: `<div style="margin-top:8px;color:var(--muted)">No similar cards.</div>`;
} catch (e) { $("#simout").innerHTML = `<div style="margin-top:8px">${esc(e.message)}</div>`; }
};
}
async function briefing(b) {
dialog(`<div class="dlgh"><h2>✦ Daily briefing · ${esc(b.title)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><div id="bo" class="ai-out"><span class="spin"></span> Reading the board…</div></div>`, true);
try {
const r = await api(`/api/boards/${b.id}/summary`);
$("#bo").textContent = r.summary;
$("#bo").insertAdjacentHTML("afterend", `<div class="facts">
<span class="chip late">${r.facts.overdue.length} overdue</span><span class="chip block">${r.facts.blocked.length} blocked</span>
<span class="chip soon">${r.facts.due_today.length} due today</span><span class="chip done">${r.facts.finished_last_day} finished in the last day</span></div>
<p style="color:var(--faint);font-size:12px">The numbers are counted from the board; Dodil Models only puts them into words.</p>`);
} catch (e) { $("#bo").textContent = e.message; }
}
async function archivedList(b) {
const items = await act(() => api(`/api/boards/${b.id}/archived`));
dialog(`<div class="dlgh"><h2>Archived · ${esc(b.title)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><p style="color:var(--muted);margin:0 0 10px">Archived cards are off the board, never deleted: their number, history and comments stay.</p>
${items.map(c => `<div class="blk"><span class="num">#${c.number}</span>${esc(c.title)}
<span class="w" style="margin-left:auto;color:var(--faint);font-size:12px">${esc((c.archived_by || "").split("@")[0])}</span>
<button class="btn" data-open-arch="${esc(c.id)}">Open</button>${b.can_edit ? `<button class="btn" data-restore="${esc(c.id)}">Restore</button>` : ""}</div>`).join("")
|| `<div class="empty">Nothing is archived.</div>`}</div>`, true);
layer.querySelectorAll("[data-restore]").forEach(x => x.onclick = async () => {
const r = await act(() => api(`/api/cards/${encodeURIComponent(x.dataset.restore)}/restore`, { method: "POST" }), "Restored");
close(); await showBoard(b.id); toast(`#${r.card} is back in ${r.list}`); });
layer.querySelectorAll("[data-open-arch]").forEach(x => x.onclick = () => openCard(x.dataset.openArch));
}
async function activity(b) {
const items = await act(() => api(`/api/boards/${b.id}/activity`));
dialog(`<div class="dlgh"><h2>Activity · ${esc(b.title)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><div class="tl">${items.map(h => `<div class="e"><div>${avatar(h.actor)} <b>${esc(h.actor.split("@")[0])}</b> ${esc(h.summary)}</div><div class="w">${esc(fmtTime(h.at))}</div></div>`).join("") || "Nothing yet."}</div></div>`, true);
}
function members(b) {
dialog(`<div class="dlgh"><h2>${b.is_owner ? "Members" : "People"} · ${esc(b.title)}</h2><button class="btn icon ghost" data-x>✕</button></div>
<div class="dlgb one"><div>
${b.members.map(m => `<div class="member">${avatar(m.email)}<span class="em">${esc(m.email)}</span>
${b.is_owner && m.role !== "owner" ? `<select class="field" style="width:auto" data-role="${esc(m.email)}"><option ${m.role === "member" ? "selected" : ""}>member</option><option ${m.role === "viewer" ? "selected" : ""}>viewer</option></select>
<button class="btn icon ghost" data-rm="${esc(m.email)}" aria-label="Remove ${esc(m.email)}">✕</button>` : `<span class="role ${esc(m.role)}">${esc(m.role)}</span>`}</div>`).join("")}
${b.is_owner ? `<form class="inline" id="inv" style="margin-top:14px"><input class="field" type="email" placeholder="teammate@company.com" required>
<select class="field" style="flex:0 0 110px"><option>member</option><option>viewer</option></select><button class="btn primary">Invite</button></form>
<p style="color:var(--faint);font-size:12px">They sign in with this email. Members can edit; viewers can only look. Removing someone unassigns their cards.</p>` : `<p style="color:var(--faint);font-size:12px">Only the owner manages who is on this board.</p>`}
</div></div>`, true);
if (!b.is_owner) return;
$("#inv").onsubmit = async e => { e.preventDefault(); const [em, role] = [e.target[0].value, e.target[1].value];
await act(() => api(`/api/boards/${b.id}/members`, { method: "PUT", body: { email: em, role } }), "Invited " + em);
await showBoard(b.id); members(state.board); };
layer.querySelectorAll("[data-role]").forEach(s => s.onchange = async () => {
await act(() => api(`/api/boards/${b.id}/members`, { method: "PUT", body: { email: s.dataset.role, role: s.value } }), "Role changed");
await showBoard(b.id); members(state.board); });
layer.querySelectorAll("[data-rm]").forEach(x => x.onclick = async () => {
if (!confirm(`Remove ${x.dataset.rm} from ${b.title}? Their cards will be unassigned.`)) return;
const r = await act(() => api(`/api/boards/${b.id}/members/${encodeURIComponent(x.dataset.rm)}`, { method: "DELETE" }));
toast(`Removed. ${r.cards_unassigned} card(s) unassigned.`); await showBoard(b.id); members(state.board); });
}
// ---------------------------------------------------------------- my week, overdue, search
function rows(items) {
return items.map(c => `<div class="row" data-open="${esc(c.id)}" data-board="${esc(c.board_id)}" tabindex="0">
<span class="num" style="color:var(--faint);font-weight:700">#${c.number}</span><span>${esc(c.title)}</span>
${dueChip(c)}${c.blocked_by.length ? `<span class="chip block">⛓ blocked</span>` : ""}
<span class="where">${esc(c.board)} › ${esc(c.list)}</span>${avatar(c.assignee)}</div>`).join("");
}
function wireRows() {
main.querySelectorAll("[data-open]").forEach(r => {
const go = async () => { state.board = await api("/api/boards/" + r.dataset.board); openCard(r.dataset.open); };
r.onclick = go; r.onkeydown = e => { if (e.key === "Enter") go(); };
});
}
async function showWeek() {
state.board = null; await loadBoards();
const w = await api("/api/me/week");
const total = w.overdue.length + w.due_soon.length + w.no_due_date.length + w.later.length;
$("#c-week").textContent = total || "";
main.innerHTML = `<div class="page"><h1>My week</h1><p class="sub">Everything open that's assigned to you, across every board you're on.</p>
${total ? "" : `<div class="empty"><span class="big">🐍</span>Nothing on your plate. Enjoy it.</div>`}
${w.overdue.length ? `<div class="group hot">Overdue · ${w.overdue.length}</div>${rows(w.overdue)}` : ""}
${w.due_soon.length ? `<div class="group">Due in the next 7 days · ${w.due_soon.length}</div>${rows(w.due_soon)}` : ""}
${w.no_due_date.length ? `<div class="group">No due date · ${w.no_due_date.length}</div>${rows(w.no_due_date)}` : ""}
${w.later.length ? `<div class="group">Later · ${w.later.length}</div>${rows(w.later)}` : ""}</div>`;
wireRows();
}
async function showOverdue() {
state.board = null; await loadBoards();
const items = await api("/api/overdue");
main.innerHTML = `<div class="page"><h1>Overdue</h1><p class="sub">Open cards past their due date on your boards. Cards in a done list never show up here.</p>
${items.length ? rows(items) : `<div class="empty"><span class="big">✓</span>Nothing is overdue.</div>`}</div>`;
wireRows();
}
async function showSearch(q) {
const items = await api("/api/search?q=" + encodeURIComponent(q));
main.innerHTML = `<div class="page"><h1>Search</h1><p class="sub">Cards mentioning “${esc(q)}”. <button class="btn ai" id="bymeaning">Search by meaning instead</button></p>
<div id="res">${items.length ? rows(items) : `<div class="empty">No card mentions “${esc(q)}”.</div>`}</div></div>`;
wireRows();
$("#bymeaning").onclick = async () => {
$("#res").innerHTML = `<div class="empty"><span class="spin"></span> Searching by meaning…</div>`;
const r = await act(() => api("/api/similar", { method: "POST", body: { text: q } }));
$("#res").innerHTML = r.cards.length ? r.cards.map(c => `<div class="row"><span class="num" style="color:var(--faint);font-weight:700">#${c.number}</span>${esc(c.title)}<span class="where">${esc(c.board)} › ${esc(c.list)} · ${Math.round(c.similarity * 100)}% alike</span></div>`).join("") : `<div class="empty">Nothing similar.</div>`;
};
}
// ---------------------------------------------------------------- routing
async function route() {
const h = location.hash || "#/";
try {
if (h.startsWith("#/b/")) await showBoard(decodeURIComponent(h.slice(4)));
else if (h.startsWith("#/c/")) { const id = decodeURIComponent(h.slice(4)); const bid = id.replace(/-\d+$/, "");
if (!state.board || state.board.id !== bid) await showBoard(bid); openCard(id); }
else if (h === "#/week") await showWeek();
else if (h === "#/overdue") await showOverdue();
else if (h.startsWith("#/search/")) await showSearch(decodeURIComponent(h.slice(9)));
else { await loadBoards();
// start on the busiest board (most open cards), not the first one alphabetically
const start = [...state.boards].sort((a, b) => b.open_cards - a.open_cards || b.overdue - a.overdue)[0];
if (start) location.replace("#/b/" + start.id); else await showWelcome(); }
} catch (e) {
if (e.message === "sign in") return;
main.innerHTML = `<div class="page"><h1>${e.status === 404 ? "Not found" : "Something went wrong"}</h1><p class="sub">${esc(e.message)}</p><a class="btn" href="#/">Back to my boards</a></div>`;
}
document.querySelectorAll(".side .nav[data-go]").forEach(n => n.classList.toggle("on", n.dataset.go === h || (state.board && n.dataset.go === "#/b/" + state.board.id && !h.startsWith("#/week") && !h.startsWith("#/overdue"))));
}
async function showWelcome() {
main.innerHTML = `<div class="page"><img class="welcome-logo" src="/static/cobra-192.png" alt="Cobra Boards"><h1>Welcome to Cobra Boards</h1>
<p class="sub">Boards → lists → cards, for small teams. Run it here, or from Claude and ChatGPT: they see exactly the boards you see.</p>
<form class="inline" id="first" style="max-width:480px"><input class="field" placeholder="Name your first board, e.g. Product launch" maxlength="80" required><button class="btn primary">Create board</button></form></div>`;
$("#first").onsubmit = e => { e.preventDefault(); newBoard(e.target[0].value.trim()); };
}
window.addEventListener("hashchange", route);
(async () => {
markTheme();
try { state.me = await api("/api/me"); $("#who").textContent = state.me.email; $("#meav").innerHTML = avatar(state.me.email); } catch (e) { return; }
route(); loadNotes();
})();